Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/home/display latest discussions #3154

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ffae686
feat(wip): discussion on page home
andriacap Jul 25, 2024
aea50e1
feat(wip): column responsibility changed + orderby
andriacap Jul 26, 2024
7e30aa8
feat(back): add test for route list_reports
andriacap Jul 26, 2024
fe879b6
fix: sort dir / orderby and pages
andriacap Jul 29, 2024
32aaa8d
feat: add config for LATEST_DISCUSSIONS
andriacap Jul 29, 2024
6eaa269
feat(front): add column mode force
andriacap Aug 19, 2024
cf3cf24
style(backend): apply linter black
andriacap Aug 19, 2024
783b9d9
style(frontend): apply prettier
andriacap Aug 19, 2024
17f6284
refactor: change checkbox to mat-slide-toggle
andriacap Aug 19, 2024
d0205c0
refactor: split code in components
andriacap Aug 20, 2024
ccbafc4
refact(frontend): remove generic table
andriacap Aug 21, 2024
48ca4f6
refactor(frontend): use standalone
andriacap Aug 21, 2024
2e2537d
refactor(frontend): make selected row works
andriacap Aug 21, 2024
eb04139
refactor(frontend): add condition to display/hide
andriacap Aug 21, 2024
2da923f
refactor: move config LATEST_DISCUSSION sample
andriacap Aug 28, 2024
de66c74
feat(frontend): use same css from other datatable
andriacap Aug 28, 2024
a79b282
refactore: set default True for LATEST_DISCUSSION
andriacap Aug 28, 2024
a111dd8
fix(frontend): undefined pageChanged Method
andriacap Aug 28, 2024
97cdc93
feat(frontend): add information icon and click evt
andriacap Aug 28, 2024
8a0d65a
style: remove console.log
andriacap Aug 28, 2024
ce49260
style(frontend): apply prettier
andriacap Aug 28, 2024
515a769
feat: add filtered count result to route /reports
andriacap Aug 28, 2024
50cc23e
styl(frontend): apply linter prettier
andriacap Aug 28, 2024
b9e6d01
fix: style and comment
andriacap Sep 3, 2024
e04c8e8
fix: orderby, joinedload, split routes
andriacap Sep 3, 2024
b84a0e9
fix(test): wip - split test related reports routes
andriacap Sep 4, 2024
18ee0ad
fix: change way to read permission synthese
andriacap Sep 16, 2024
d38eea7
feat: add missing test for pin case in list_reports
edelclaux Sep 17, 2024
edfec67
feat: add missing test for unknow type in list_reports
edelclaux Sep 18, 2024
9575545
test: dummy commit to trigger ci pipeline
edelclaux Sep 18, 2024
7333851
test: add undefined test in lsit_all_reports
edelclaux Sep 30, 2024
efd9d4e
test: adjust phrasing
edelclaux Sep 30, 2024
7d768a9
test: add nortigjtuser scenario in list_reports
edelclaux Sep 30, 2024
4803996
feat(routes, report) : translate to sqla2.0
jacquesfize Oct 4, 2024
8346915
fix(test) : sorted does not work exactly like psql sort
jacquesfize Oct 4, 2024
0306bf0
fix(blueprint,report): change query to feat flasksqlalchemy paginate(…
jacquesfize Oct 4, 2024
8c2b874
fix(latest_discussions): fix number of page
jacquesfize Oct 4, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

C'est une bonne pratique ça de faire la pagination à la mano ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dans GeoNature, c'est flask-sqlalchemy qui fait la pagination. Le soucis, c'est qu'ils utilisent la méthode scalars() lors de l'exécution d'une requête construite avec un objet Select (remplace Query dans SQLA 2.0) (https://github.com/pallets-eco/flask-sqlalchemy/blob/main/src/flask_sqlalchemy/pagination.py#L328-L364). Ici, c'est pas ce que l'on souhaite. SI on veut que la pagination flask-sqlalchemy fonctionne ici, il faudrait passer par une requête construite avec un object Query mais qui n'est plus recommandé dans SQLA 2.0 😕

Du coup, je propose de faire la pagination "à la main" en s'appuyant sur quasi le même fonctionnement de flask-sqlalchemy.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On peut aussi modifier la requête, récupérer le nom_complet de l'utilisateur dans un joinedload et repasser sur la pagination flask-sqlalchemy, ça devrait marcher 🤔

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

c'est fait :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Top, merci !

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
Loading