diff --git a/collectives/forms/stats.py b/collectives/forms/stats.py new file mode 100644 index 00000000..c83243af --- /dev/null +++ b/collectives/forms/stats.py @@ -0,0 +1,42 @@ +""" Form for statistics display and parameters """ + +from datetime import date + +from wtforms import SelectField, SubmitField + +from collectives.forms.activity_type import ActivityTypeSelectionForm + + +class StatisticsParametersForm(ActivityTypeSelectionForm): + """Parameters for statistics page""" + + year = SelectField() + """ Year to display """ + + submit = SubmitField(label="Sélectionner") + """ Submit button for regular HTML display """ + + excel = SubmitField(label="Export Excel") + """ Submit button for excel download """ + + def __init__(self, *args, **kwargs): + """Creates a new form""" + current_year = date.today().year - 2000 + if date.today().month < 9: + current_year = current_year - 1 + + super().__init__(year=2000 + current_year, *args, **kwargs) + self.activity_id.choices = [(9999, "Toute activité")] + self.activity_id.choices + current_year = date.today().year - 2000 + self.year.choices = [ + (2000 + year, f"Année 20{year}/{year+1}") + for year in range(20, current_year) + ] + + class Meta: + """Form meta parameters""" + + csrf = False + """ CSRF parameter. + + It is deactivated for this form""" diff --git a/collectives/models/activity_type.py b/collectives/models/activity_type.py index 1ba4ab4f..8d7c12f5 100644 --- a/collectives/models/activity_type.py +++ b/collectives/models/activity_type.py @@ -70,6 +70,10 @@ class ActivityType(db.Model): :type: :py:class:`collectives.models.user.User` """ + def __str__(self) -> str: + """Displays the user name.""" + return self.name + f" (ID {self.id})" + @validates("trigram") def truncate_string(self, key, value): """Truncates a string to the max SQL field length diff --git a/collectives/models/event/date.py b/collectives/models/event/date.py index 292794b9..eaa6275a 100644 --- a/collectives/models/event/date.py +++ b/collectives/models/event/date.py @@ -1,6 +1,10 @@ """ Module for all Event methods related to date manipulation and check.""" +from datetime import timedelta +from math import ceil + + class EventDateMixin: """Part of Event class for date manipulation and check. @@ -72,3 +76,24 @@ def dates_intersect(self, start, end): and (start <= self.end) and (start <= end) ) + + def volunteer_duration(self) -> int: + """Estimate event duration for volunterring purposes. + + If start and end are the same, it means the event has no hours. Thus, it is considered as a + day long. If not, 2h is a quarter of a day, and math is round up. + + :param event: the event to get the duration of. + :returns: number of day of the event + """ + if self.start == self.end: + return 1 + duration = self.end - self.start + + if duration > timedelta(hours=4): + return ceil(duration / timedelta(days=1)) + + if duration > timedelta(hours=2): + return 0.5 + + return 0.25 diff --git a/collectives/models/event_tag.py b/collectives/models/event_tag.py index c276b7eb..16df884c 100644 --- a/collectives/models/event_tag.py +++ b/collectives/models/event_tag.py @@ -29,7 +29,8 @@ class EventTag(db.Model): :type: :py:class:`collectives.models.event_tag.EventTagTypes`""" event_id = db.Column(db.Integer, db.ForeignKey("events.id")) - """ Primary key of the registered user (see :py:class:`collectives.models.user.User`) + """ Primary key of the event which holds this tag (see + :py:class:`collectives.models.event.Event`) :type: int""" @@ -58,7 +59,9 @@ def csv_code(self): """Short name of the tag type, used as css class :type: string""" - return self.full["csv_code"] or self.full["name"] + if "csv_code" in self.full: + return self.full["csv_code"] + return self.full["name"] @property def full(self): diff --git a/collectives/models/registration.py b/collectives/models/registration.py index cd365217..d7ce73d1 100644 --- a/collectives/models/registration.py +++ b/collectives/models/registration.py @@ -129,7 +129,14 @@ def is_valid(self): :returns: True or False :rtype: bool""" - return self in [RegistrationStatus.Active, RegistrationStatus.Present] + return self in RegistrationStatus.valid_status() + + @classmethod + def valid_status(cls) -> list: + """Returns the list of registration status considered as valid. + + See :py:meth:`collectives.models.registration.RegistrationStatus.is_valid()`""" + return [RegistrationStatus.Active, RegistrationStatus.Present] def valid_transitions(self, requires_payment): """ diff --git a/collectives/models/user/misc.py b/collectives/models/user/misc.py index 2af6fb25..1d171e79 100644 --- a/collectives/models/user/misc.py +++ b/collectives/models/user/misc.py @@ -113,6 +113,10 @@ def full_name(self): """ return f"{self.first_name} {self.last_name.upper()}" + def __str__(self) -> str: + """Displays the user name.""" + return self.full_name() + f" (ID {self.id})" + def abbrev_name(self): """Get user first name and first letter of last name. diff --git a/collectives/models/utils.py b/collectives/models/utils.py index e6709c6e..b49b2a69 100644 --- a/collectives/models/utils.py +++ b/collectives/models/utils.py @@ -55,6 +55,10 @@ def display_name(self): cls = self.__class__ return cls.display_names()[self.value] + def __str__(self) -> str: + """Displays the instance name.""" + return self.display_name() + def __len__(self): """Bogus length function diff --git a/collectives/routes/root.py b/collectives/routes/root.py index 1512848b..2202f207 100644 --- a/collectives/routes/root.py +++ b/collectives/routes/root.py @@ -2,13 +2,16 @@ This modules contains the root Blueprint """ -from flask import redirect, url_for, Blueprint -from flask import render_template +from flask import redirect, url_for, Blueprint, send_file +from flask import render_template, request from flask_login import current_user, login_required from collectives.forms.auth import LegalAcceptation +from collectives.forms.stats import StatisticsParametersForm +from collectives.forms import csrf from collectives.models import db, Configuration from collectives.utils.time import current_time +from collectives.utils.stats import StatisticsEngine blueprint = Blueprint("root", __name__) @@ -36,3 +39,30 @@ def legal_accept(): db.session.add(current_user) db.session.commit() return redirect(url_for("root.legal")) + + +@blueprint.route("/stats") +@csrf.exempt +@login_required +def statistics(): + """Displays site event statistics.""" + form = StatisticsParametersForm(formdata=request.args) + if form.validate(): + if form.activity_id.data == 9999: + engine = StatisticsEngine(year=form.year.data) + else: + engine = StatisticsEngine( + activity_id=form.activity_id.data, year=form.year.data + ) + else: + engine = StatisticsEngine(year=StatisticsParametersForm().year.data) + + if "excel" in request.args: + return send_file( + engine.export_excel(), + mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + download_name="Statistiques Collectives.xlsx", + as_attachment=True, + ) + + return render_template("stats/stats.html", engine=engine, form=form) diff --git a/collectives/static/css/components/_index.scss b/collectives/static/css/components/_index.scss index c41146bf..267541b5 100644 --- a/collectives/static/css/components/_index.scss +++ b/collectives/static/css/components/_index.scss @@ -17,3 +17,4 @@ @import 'card'; @import 'usericon'; @import 'technician-configuration'; +@import 'stats'; diff --git a/collectives/static/css/components/stats.scss b/collectives/static/css/components/stats.scss new file mode 100644 index 00000000..cdfc2559 --- /dev/null +++ b/collectives/static/css/components/stats.scss @@ -0,0 +1,50 @@ +.card { + position: relative; + border-radius: 6px; + box-shadow: 0 .5em 1em -.125em rgba(10,10,10,.1),0 0 0 1px rgba(10,10,10,.02); + color: #4a4a4a; + display: block; + padding: 1.25rem; + border: 0.5px solid #e6e6e6; + + .tooltip{ + position: absolute; + bottom: 5px; + right: 10px; + + .text { + visibility: hidden; + min-width: 300px; + background-color: rgb(51, 51, 51); + color: #fff; + text-align: justify; + padding: 10px; + border-radius: 6px; + position: absolute; + z-index: 1; + right: 105%; + } + + img{ + height: 1.5em; + width: 1.5em; + opacity: 0.75; + } + } + .tooltip:hover .text, .tooltip:active .text { + visibility: visible; + } +} + +.card.single-stat{ + text-align: center; + + .value{ + font-size: 4em; + font-weight: bold; + } + + .header-3{ + font-weight: normal; + } +} \ No newline at end of file diff --git a/collectives/templates/partials/main-navigation.html b/collectives/templates/partials/main-navigation.html index 0e0e83bb..dc3b1ea9 100644 --- a/collectives/templates/partials/main-navigation.html +++ b/collectives/templates/partials/main-navigation.html @@ -29,6 +29,14 @@ + {# Deactivated : awaiting test + #} + {% if current_user.can_create_events() %}