Skip to content

Commit

Permalink
[FEATURE] Add event statictics
Browse files Browse the repository at this point in the history
  • Loading branch information
jnguiot committed Aug 18, 2023
1 parent 8d8a03a commit ac647c1
Show file tree
Hide file tree
Showing 37 changed files with 1,409 additions and 6 deletions.
42 changes: 42 additions & 0 deletions collectives/forms/stats.py
Original file line number Diff line number Diff line change
@@ -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"""
4 changes: 4 additions & 0 deletions collectives/models/activity_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions collectives/models/event/date.py
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
7 changes: 5 additions & 2 deletions collectives/models/event_tag.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""

Expand Down Expand Up @@ -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):
Expand Down
9 changes: 8 additions & 1 deletion collectives/models/registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down
4 changes: 4 additions & 0 deletions collectives/models/user/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions collectives/models/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 32 additions & 2 deletions collectives/routes/root.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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)
1 change: 1 addition & 0 deletions collectives/static/css/components/_index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@
@import 'card';
@import 'usericon';
@import 'technician-configuration';
@import 'stats';
50 changes: 50 additions & 0 deletions collectives/static/css/components/stats.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
8 changes: 8 additions & 0 deletions collectives/templates/partials/main-navigation.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@
</a>
</li>

{# Deactivated : awaiting test
<li class="menu-dropdown-item">
<a href="{{ url_for('root.statistics') }}" class="menu-dropdown-item-link">
<img src="{{ url_for('static', filename='img/icon/ionicon/analytics-sharp.svg') }}" class="legacy-icon" />
Statistiques
</a>
</li> #}

{% if current_user.can_create_events() %}
<li class="menu-dropdown-item">
<a href="{{ url_for('event.manage_event')}}" class="menu-dropdown-item-link"><img
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<div class="heading-3">{{ engine.INDEX['mean_collectives_per_day']['name'] }}</div>
<div class="value">{% if engine.mean_collectives_per_day() %}{{engine.mean_collectives_per_day() | round(2)}}{%else%}-{% endif %}</div>
<div class="tooltip">
<img src="{{ url_for('static', filename='img/icon/ionicon/md-information-circle-outline.svg') }}" alt="(I)" />
<span class="text">{{ engine.INDEX['mean_collectives_per_day']['description'] }}</span>
</div>
6 changes: 6 additions & 0 deletions collectives/templates/stats/partials/mean_events_per_day.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<div class="heading-3">{{ engine.INDEX['mean_events_per_day']['name'] }}</div>
<div class="value">{% if engine.mean_events_per_day() %}{{engine.mean_events_per_day() | round(2)}}{%else%}-{% endif %}</div>
<div class="tooltip">
<img src="{{ url_for('static', filename='img/icon/ionicon/md-information-circle-outline.svg') }}" alt="(I)" />
<span class="text">{{ engine.INDEX['mean_events_per_day']['description'] }}</span>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<div class="heading-3">{{ engine.INDEX['mean_registrations_per_day']['name'] }}</div>
<div class="value">{% if engine.mean_registrations_per_day() %}{{engine.mean_registrations_per_day() | round(2)}}{%else%}-{% endif %}</div>
<div class="tooltip">
<img src="{{ url_for('static', filename='img/icon/ionicon/md-information-circle-outline.svg') }}" alt="(I)" />
<span class="text">{{ engine.INDEX['mean_registrations_per_day']['description'] }}</span>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<div class="heading-3">{{ engine.INDEX['mean_registrations_per_event']['name'] }}</div>
<div class="value">{{engine.mean_registrations_per_event() | round(2)}}</div>
<div class="tooltip">
<img src="{{ url_for('static', filename='img/icon/ionicon/md-information-circle-outline.svg') }}" alt="(I)" />
<span class="text">{{ engine.INDEX['mean_registrations_per_event']['description'] }}</span>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@

<div class="heading-3">{{ engine.INDEX['nb_active_registrations']['name'] }}</div>
<div class="value">{{engine.nb_active_registrations()}}</div>
<div class="tooltip">
<img src="{{ url_for('static', filename='img/icon/ionicon/md-information-circle-outline.svg') }}" alt="(I)" />
<span class="text">{{ engine.INDEX['nb_active_registrations']['description'] }}</span>
</div>
6 changes: 6 additions & 0 deletions collectives/templates/stats/partials/nb_collectives.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<div class="heading-3">Nombre d'évènements</div>
<div class="value">{{engine.nb_collectives()}}</div>
<div class="tooltip">
<img src="{{ url_for('static', filename='img/icon/ionicon/md-information-circle-outline.svg') }}" alt="(I)" />
<span class="text">{{ engine.INDEX['nb_collectives']['description'] }}</span>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<canvas id="collectives-activity-chart"></canvas>

<div class="tooltip">
<img src="{{ url_for('static', filename='img/icon/ionicon/md-information-circle-outline.svg') }}" alt="(I)" />
<span class="text">{{ engine.INDEX['nb_collectives_by_activity_type']['description'] }}</span>
</div>

<script>
new Chart(document.getElementById('collectives-activity-chart'), {
type: 'bar',
data: {
labels: {{ engine.nb_collectives_by_activity_type().keys() | list | tojson | safe }},
datasets: [{
data: {{ engine.nb_collectives_by_activity_type().values() | list | tojson|safe }},
}]
},
options: {
aspectRatio: 3,
plugins: {
title: {
text: "{{ engine.INDEX['nb_collectives_by_activity_type']['name'] | safe }}",
}
}, scales: {y:{grace:"15%"}}
}
});
</script>
6 changes: 6 additions & 0 deletions collectives/templates/stats/partials/nb_events.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<div class="heading-3">Nombre d'évènements</div>
<div class="value">{{engine.nb_events()}}</div>
<div class="tooltip">
<img src="{{ url_for('static', filename='img/icon/ionicon/md-information-circle-outline.svg') }}" alt="(I)" />
<span class="text">{{ engine.INDEX['nb_events']['description'] }}</span>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<canvas id="activity-chart"></canvas>

<div class="tooltip">
<img src="{{ url_for('static', filename='img/icon/ionicon/md-information-circle-outline.svg') }}" alt="(I)" />
<span class="text">{{ engine.INDEX['nb_events_by_activity_type']['description'] }}</span>
</div>

<script>
new Chart(document.getElementById('activity-chart'), {
type: 'bar',
data: {
labels: {{ engine.nb_events_by_activity_type().keys() | list | tojson | safe }},
datasets: [{
data: {{ engine.nb_events_by_activity_type().values() | list | tojson|safe }},
}]
},
options: {
aspectRatio: 3,
plugins: {
title: {
text: "{{ engine.INDEX['nb_events_by_activity_type']['name'] | safe }}",
}
}, scales: {y:{grace:"15%"}}
}
});
</script>
Loading

0 comments on commit ac647c1

Please sign in to comment.