Skip to content

Commit

Permalink
Feat/home/display latest discussions (#3154)
Browse files Browse the repository at this point in the history
* Add latest-discussion widget on homepage
* feat(back): add test for route list_reports
* feat: add config for LATEST_DISCUSSIONS
---------

Co-authored-by: Etienne Delclaux <etienne_delclaux@natural-solutions.eu>
Co-authored-by: jacquesfize <jacques.fize@ecrins-parcnational.fr>
  • Loading branch information
3 people authored Oct 4, 2024
1 parent c4bf838 commit 73e1ecb
Show file tree
Hide file tree
Showing 21 changed files with 678 additions and 55 deletions.
171 changes: 132 additions & 39 deletions backend/geonature/core/gn_synthese/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
jsonify,
g,
)
from geonature.core.gn_synthese.schemas import SyntheseSchema
from geonature.core.gn_synthese.schemas import ReportSchema, SyntheseSchema
from geonature.core.gn_synthese.synthese_config import MANDATORY_COLUMNS
from pypnusershub.db.models import User
from pypnnomenclature.models import BibNomenclaturesTypes, TNomenclatures
Expand Down Expand Up @@ -1490,53 +1490,146 @@ def update_content_report(id_report):

@routes.route("/reports", methods=["GET"])
@permissions_required("R", module_code="SYNTHESE")
def list_reports(permissions):
def list_all_reports(permissions):
# Parameters
type_name = request.args.get("type")
id_synthese = request.args.get("idSynthese")
orderby = request.args.get("orderby", "creation_date")
sort = request.args.get("sort")
# VERIFY ID SYNTHESE
page = int(request.args.get("page", 1))
per_page = int(request.args.get("per_page", 10))
my_reports = request.args.get("my_reports", "false").lower() == "true"

# Start query
query = (
sa.select(TReport, User.nom_complet)
.join(User, TReport.id_role == User.id_role)
.options(
joinedload(TReport.report_type).load_only(
BibReportsTypes.type, BibReportsTypes.id_type
),
joinedload(TReport.synthese).load_only(
Synthese.cd_nom,
Synthese.nom_cite,
Synthese.observers,
Synthese.date_min,
Synthese.date_max,
),
joinedload(TReport.user).load_only(User.nom_role, User.prenom_role),
)
)
# Verify and filter by type
if type_name:
type_exists = db.session.scalar(
sa.exists(BibReportsTypes).where(BibReportsTypes.type == type_name).select()
)
if not type_exists:
raise BadRequest("This report type does not exist")
query = query.where(TReport.report_type.has(BibReportsTypes.type == type_name))

# Filter by id_role for 'pin' type only or if my_reports is true
if type_name == "pin" or my_reports:
query = query.where(TReport.id_role == g.current_user.id_role)

# On vérifie les permissions en lecture sur la synthese
synthese_query = select(Synthese.id_synthese).select_from(Synthese)
synthese_query_obj = SyntheseQuery(Synthese, synthese_query, {})
synthese_query_obj.filter_query_with_cruved(g.current_user, permissions)
ids_synthese = db.session.scalars(synthese_query_obj.query).all()
query = query.where(TReport.id_synthese.in_(ids_synthese))

SORT_COLUMNS = {
"user.nom_complet": User.nom_complet,
"content": TReport.content,
"creation_date": TReport.creation_date,
}

# Determine the sorting
if orderby in SORT_COLUMNS:
sort_column = SORT_COLUMNS[orderby]
if sort == "desc":
query = query.order_by(desc(sort_column))
else:
query = query.order_by(asc(sort_column))
else:
raise BadRequest("Bad orderby")

# Pagination
total = db.session.scalar(
select(func.count("*"))
.select_from(TReport)
.where(TReport.report_type.has(BibReportsTypes.type == type_name))
)
paginated_results = db.paginate(query, page=page, per_page=per_page)

result = []

for report in paginated_results.items:
report_dict = {
"id_report": report.id_report,
"id_synthese": report.id_synthese,
"id_role": report.id_role,
"report_type": {
"type": report.report_type.type,
"id_type": report.report_type.id_type,
},
"content": report.content,
"deleted": report.deleted,
"creation_date": report.creation_date,
"user": {"nom_complet": report.user.nom_complet},
"synthese": {
"cd_nom": report.synthese.cd_nom,
"nom_cite": report.synthese.nom_cite,
"observers": report.synthese.observers,
"date_min": report.synthese.date_min,
"date_max": report.synthese.date_max,
},
}
result.append(report_dict)

response = {
"total_filtered": paginated_results.total,
"total": total,
"pages": paginated_results.pages,
"current_page": page,
"per_page": per_page,
"items": result,
}
return jsonify(response)


@routes.route("/reports/<int:id_synthese>", methods=["GET"])
@permissions_required("R", module_code="SYNTHESE")
def list_reports(permissions, id_synthese):
type_name = request.args.get("type")

synthese = db.get_or_404(Synthese, id_synthese)
if not synthese.has_instance_permission(permissions):
raise Forbidden
# START REQUEST
req = TReport.query.where(TReport.id_synthese == id_synthese)
# SORT
if sort == "asc":
req = req.order_by(asc(TReport.creation_date))
if sort == "desc":
req = req.order_by(desc(TReport.creation_date))
# VERIFY AND SET TYPE
type_exists = BibReportsTypes.query.filter_by(type=type_name).one_or_none()
# type param is not required to get all
if type_name and not type_exists:
raise BadRequest("This report type does not exist")

query = sa.select(TReport).where(TReport.id_synthese == id_synthese)

# Verify and filter by type
if type_name:
req = req.where(TReport.report_type.has(BibReportsTypes.type == type_name))
# filter by id_role for pin type only
if type_name and type_name == "pin":
req = req.where(TReport.id_role == g.current_user.id_role)
req = req.options(
type_exists = db.session.scalar(
sa.exists(BibReportsTypes).where(BibReportsTypes.type == type_name).select()
)
if not type_exists:
raise BadRequest("This report type does not exist")
query = query.where(TReport.report_type.has(BibReportsTypes.type == type_name))

# Filter by id_role for 'pin' type only
if type_name == "pin":
query = query.where(TReport.id_role == g.current_user.id_role)

# Join the User table
query = query.options(
joinedload(TReport.user).load_only(User.nom_role, User.prenom_role),
joinedload(TReport.report_type),
)
result = [
report.as_dict(
fields=[
"id_report",
"id_synthese",
"id_role",
"report_type.type",
"report_type.id_type",
"content",
"deleted",
"creation_date",
"user.nom_role",
"user.prenom_role",
]
)
for report in req.all()
]
return jsonify(result)

return ReportSchema(many=True, only=["+user.nom_role", "+user.prenom_role"]).dump(
db.session.scalars(query).all()
)


@routes.route("/reports/<int:id_report>", methods=["DELETE"])
Expand Down
2 changes: 2 additions & 0 deletions backend/geonature/tests/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -845,6 +845,8 @@ def create_report(id_synthese, id_role, content, id_type, deleted):
reports = [
(ids[0], users["admin_user"].id_role, "comment1", discussionId, False),
(ids[1], users["admin_user"].id_role, "comment1", alertId, False),
(ids[2], users["user"].id_role, "a_comment1", discussionId, True),
(ids[3], users["user"].id_role, "b_comment1", discussionId, True),
]
for id_synthese, *args in reports:
data.append(create_report(id_synthese, *args))
Expand Down
113 changes: 105 additions & 8 deletions backend/geonature/tests/test_reports.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json

from datetime import datetime
import pytest
from flask import url_for
from sqlalchemy import func, select, exists
Expand Down Expand Up @@ -144,29 +145,125 @@ def test_delete_report(self, reports_data, users):

def test_list_reports(self, reports_data, synthese_data, users):
url = "gn_synthese.list_reports"
# TEST GET WITHOUT REQUIRED ID SYNTHESE
set_logged_user(self.client, users["admin_user"])
response = self.client.get(url_for(url))
assert response.status_code == NotFound.code
ids = [s.id_synthese for s in synthese_data.values()]

# User: noright_user
set_logged_user(self.client, users["noright_user"])
response = self.client.get(
url_for(
url, id_synthese=ids[0], idRole=users["noright_user"].id_role, type="discussion"
)
)
assert response.status_code == Forbidden.code

# User: admin_user
set_logged_user(self.client, users["admin_user"])

# TEST GET BY ID SYNTHESE
response = self.client.get(
url_for(url, idSynthese=ids[0], idRole=users["admin_user"].id_role, type="discussion")
url_for(url, id_synthese=ids[0], idRole=users["admin_user"].id_role, type="discussion")
)
assert response.status_code == 200
assert len(response.json) == 1

# TEST INVALID - TYPE DOES NOT EXISTS
response = self.client.get(
url_for(
url,
id_synthese=ids[0],
idRole=users["admin_user"].id_role,
type="UNKNOW-REPORT-TYPE",
)
)
assert response.status_code == 400
assert response.json["description"] == "This report type does not exist"

# TEST VALID - ADD PIN
response = self.client.get(
url_for(url, id_synthese=ids[0], idRole=users["admin_user"].id_role, type="pin")
)
assert response.status_code == 200
assert len(response.json) == 0
# TEST NO RESULT
if len(ids) > 1:
# not exists because ids[1] is an alert
response = self.client.get(url_for(url, idSynthese=ids[1], type="discussion"))
response = self.client.get(url_for(url, id_synthese=ids[1], type="discussion"))
assert response.status_code == 200
assert len(response.json) == 0
# TEST TYPE NOT EXISTS
response = self.client.get(url_for(url, idSynthese=ids[1], type="foo"))
response = self.client.get(url_for(url, id_synthese=ids[1], type="foo"))
assert response.status_code == BadRequest.code
# NO TYPE - TYPE IS NOT REQUIRED
response = self.client.get(url_for(url, idSynthese=ids[1]))
response = self.client.get(url_for(url, id_synthese=ids[1]))
assert response.status_code == 200

@pytest.mark.parametrize(
"sort,orderby,expected_error",
[
("asc", "creation_date", False),
("desc", "creation_date", False),
("asc", "user.nom_complet", False),
("asc", "content", False),
("asc", "nom_cite", True),
],
)
def test_list_all_reports(
self, sort, orderby, expected_error, reports_data, synthese_data, users
):
url = "gn_synthese.list_all_reports"
set_logged_user(self.client, users["admin_user"])
# TEST GET WITHOUT REQUIRED ID SYNTHESE
response = self.client.get(url_for(url, type="discussion"))
assert response.status_code == 200
assert "items" in response.json
assert isinstance(response.json["items"], list)
assert len(response.json["items"]) >= 0

ids = [s.id_synthese for s in synthese_data.values()]
# TEST WITH MY_REPORTS TRUE
set_logged_user(self.client, users["user"])
response = self.client.get(url_for(url, type="discussion", my_reports="true"))
assert response.status_code == 200
items = response.json["items"]
# Check that all items belong to the current user
id_role = users["user"].id_role
nom_complet = users["user"].nom_complet
assert all(
item["id_role"] == id_role and item["user"]["nom_complet"] == nom_complet
for item in items
)

# Test undefined type
response = self.client.get(url_for(url, type="UNKNOW-REPORT-TYPE", my_reports="true"))
assert response.status_code == 400
assert response.json["description"] == "This report type does not exist"

# TEST SORT AND PAGINATION
if expected_error:
# Test with invalid orderby
response = self.client.get(url_for(url, orderby=orderby, sort=sort))
assert response.status_code == BadRequest.code
else:
response = self.client.get(url_for(url, orderby=orderby, sort=sort, page=1, per_page=5))
assert response.status_code == 200
assert "items" in response.json
assert len(response.json["items"]) <= 5

# Verify sorting
items = response.json["items"]
reverse_sort = sort == "desc"
if orderby == "creation_date":
dates = [
datetime.strptime(item["creation_date"], "%a, %d %b %Y %H:%M:%S %Z")
for item in items
]
assert dates == sorted(dates, reverse=reverse_sort)
elif orderby == "content":
contents = [item["content"] for item in items]
assert contents == sorted(contents, reverse=reverse_sort, key=str.casefold)
elif orderby == "user.nom_complet":
names = [item["user"]["nom_complet"] for item in items]
assert names == sorted(names, reverse=reverse_sort)


@pytest.mark.usefixtures("client_class", "notifications_enabled", "temporary_transaction")
Expand Down
1 change: 1 addition & 0 deletions backend/geonature/utils/config_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ class HomeConfig(Schema):
load_default="Texte d'introduction, configurable pour le modifier régulièrement ou le masquer"
)
FOOTER = fields.String(load_default="")
DISPLAY_LATEST_DISCUSSIONS = fields.Boolean(load_default=True)


class MetadataConfig(Schema):
Expand Down
1 change: 1 addition & 0 deletions config/default_config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,7 @@ MEDIA_CLEAN_CRONTAB = "0 1 * * *"
TITLE = "Bienvenue dans GeoNature"
INTRODUCTION = "Texte d'introduction, configurable pour le modifier régulièrement ou le masquer"
FOOTER = ""
DISPLAY_LATEST_DISCUSSIONS = true

[AUTHENTICATION]
DEFAULT_RECONCILIATION_GROUP_ID = 2
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,10 @@ export class SyntheseDataService {
document.body.removeChild(link);
}

getReports(params) {
return this._api.get(`${this.config.API_ENDPOINT}/synthese/reports?${params}`);
getReports(params, idSynthese = null) {
const baseUrl = `${this.config.API_ENDPOINT}/synthese/reports`;
const url = idSynthese ? `${baseUrl}/${idSynthese}` : baseUrl;
return this._api.get(`${url}?${params}`);
}

createReport(params) {
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import { GN2CommonModule } from '@geonature_common/GN2Common.module';
import { AppComponent } from './app.component';
import { routing } from './routing/app-routing.module'; // RoutingModule
import { HomeContentComponent } from './components/home-content/home-content.component';
import { HomeDiscussionsTableComponent } from './components/home-content/home-discussions/home-discussions-table/home-discussions-table.component';
import { HomeDiscussionsComponent } from './components/home-content/home-discussions/home-discussions.component';
import { HomeDiscussionsToggleComponent } from './components/home-content/home-discussions/home-discussions-toggle/home-discussions-toggle.component';

import { SidenavItemsComponent } from './components/sidenav-items/sidenav-items.component';
import { PageNotFoundComponent } from './components/page-not-found/page-not-found.component';
import { NavHomeComponent } from './components/nav-home/nav-home.component';
Expand Down Expand Up @@ -97,6 +101,7 @@ export function initApp(injector) {
},
}),
LoginModule,
HomeDiscussionsComponent,
],
declarations: [
AppComponent,
Expand Down
Loading

0 comments on commit 73e1ecb

Please sign in to comment.