Skip to content

Commit 76e5906

Browse files
authored
[FEATURE] Add event statictics (#642)
* [FEATURE] Add event statictics * Remarks taken into account * Variable for all activities id in form * Fix black
1 parent 8d8a03a commit 76e5906

37 files changed

+1443
-6
lines changed

collectives/forms/stats.py

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
""" Form for statistics display and parameters """
2+
3+
from datetime import date
4+
5+
from wtforms import SelectField, SubmitField
6+
7+
from collectives.forms.activity_type import ActivityTypeSelectionForm
8+
9+
10+
class StatisticsParametersForm(ActivityTypeSelectionForm):
11+
"""Parameters for statistics page"""
12+
13+
ALL_ACTIVITIES = 999999
14+
""" Id for all activity choice in `activity_id`"""
15+
16+
year = SelectField()
17+
""" Year to display """
18+
19+
submit = SubmitField(label="Sélectionner")
20+
""" Submit button for regular HTML display """
21+
22+
excel = SubmitField(label="Export Excel")
23+
""" Submit button for excel download """
24+
25+
def __init__(self, *args, **kwargs):
26+
"""Creates a new form"""
27+
current_year = date.today().year - 2000
28+
if date.today().month < 9:
29+
current_year = current_year - 1
30+
31+
super().__init__(year=2000 + current_year, *args, **kwargs)
32+
self.activity_id.choices = [
33+
(self.ALL_ACTIVITIES, "Toute activité")
34+
] + self.activity_id.choices
35+
current_year = date.today().year - 2000
36+
self.year.choices = [
37+
(2000 + year, f"Année 20{year}/{year+1}")
38+
for year in range(20, current_year)
39+
]
40+
41+
class Meta:
42+
"""Form meta parameters"""
43+
44+
csrf = False
45+
""" CSRF parameter.
46+
47+
It is deactivated for this form"""

collectives/models/activity_type.py

+4
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ class ActivityType(db.Model):
7070
:type: :py:class:`collectives.models.user.User`
7171
"""
7272

73+
def __str__(self) -> str:
74+
"""Displays the user name."""
75+
return self.name + f" (ID {self.id})"
76+
7377
@validates("trigram")
7478
def truncate_string(self, key, value):
7579
"""Truncates a string to the max SQL field length

collectives/models/event/date.py

+25
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
""" Module for all Event methods related to date manipulation and check."""
22

33

4+
from datetime import timedelta
5+
from math import ceil
6+
7+
48
class EventDateMixin:
59
"""Part of Event class for date manipulation and check.
610
@@ -72,3 +76,24 @@ def dates_intersect(self, start, end):
7276
and (start <= self.end)
7377
and (start <= end)
7478
)
79+
80+
def volunteer_duration(self) -> int:
81+
"""Estimate event duration for volunteering purposes.
82+
83+
If start and end are the same, it means the event has no hours. Thus, it is considered as a
84+
day long. If not, 2h is a quarter of a day, and math is round up.
85+
86+
:param event: the event to get the duration of.
87+
:returns: number of day of the event
88+
"""
89+
if self.start == self.end:
90+
return 1
91+
duration = self.end - self.start
92+
93+
if duration > timedelta(hours=4):
94+
return ceil(duration / timedelta(days=1))
95+
96+
if duration > timedelta(hours=2):
97+
return 0.5
98+
99+
return 0.25

collectives/models/event_tag.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ class EventTag(db.Model):
2929
:type: :py:class:`collectives.models.event_tag.EventTagTypes`"""
3030

3131
event_id = db.Column(db.Integer, db.ForeignKey("events.id"))
32-
""" Primary key of the registered user (see :py:class:`collectives.models.user.User`)
32+
""" Primary key of the event which holds this tag (see
33+
:py:class:`collectives.models.event.Event`)
3334
3435
:type: int"""
3536

@@ -58,7 +59,9 @@ def csv_code(self):
5859
"""Short name of the tag type, used as css class
5960
6061
:type: string"""
61-
return self.full["csv_code"] or self.full["name"]
62+
if "csv_code" in self.full:
63+
return self.full["csv_code"]
64+
return self.full["name"]
6265

6366
@property
6467
def full(self):

collectives/models/registration.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,14 @@ def is_valid(self):
129129
130130
:returns: True or False
131131
:rtype: bool"""
132-
return self in [RegistrationStatus.Active, RegistrationStatus.Present]
132+
return self in RegistrationStatus.valid_status()
133+
134+
@classmethod
135+
def valid_status(cls) -> list:
136+
"""Returns the list of registration status considered as valid.
137+
138+
See :py:meth:`collectives.models.registration.RegistrationStatus.is_valid()`"""
139+
return [RegistrationStatus.Active, RegistrationStatus.Present]
133140

134141
def valid_transitions(self, requires_payment):
135142
"""

collectives/models/user/misc.py

+4
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ def full_name(self):
113113
"""
114114
return f"{self.first_name} {self.last_name.upper()}"
115115

116+
def __str__(self) -> str:
117+
"""Displays the user name."""
118+
return self.full_name() + f" (ID {self.id})"
119+
116120
def abbrev_name(self):
117121
"""Get user first name and first letter of last name.
118122

collectives/models/utils.py

+4
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ def display_name(self):
5555
cls = self.__class__
5656
return cls.display_names()[self.value]
5757

58+
def __str__(self) -> str:
59+
"""Displays the instance name."""
60+
return self.display_name()
61+
5862
def __len__(self):
5963
"""Bogus length function
6064

collectives/routes/root.py

+32-2
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22
33
This modules contains the root Blueprint
44
"""
5-
from flask import redirect, url_for, Blueprint
6-
from flask import render_template
5+
from flask import redirect, url_for, Blueprint, send_file
6+
from flask import render_template, request
77
from flask_login import current_user, login_required
88

99
from collectives.forms.auth import LegalAcceptation
10+
from collectives.forms.stats import StatisticsParametersForm
11+
from collectives.forms import csrf
1012
from collectives.models import db, Configuration
1113
from collectives.utils.time import current_time
14+
from collectives.utils.stats import StatisticsEngine
1215

1316

1417
blueprint = Blueprint("root", __name__)
@@ -36,3 +39,30 @@ def legal_accept():
3639
db.session.add(current_user)
3740
db.session.commit()
3841
return redirect(url_for("root.legal"))
42+
43+
44+
@blueprint.route("/stats")
45+
@csrf.exempt
46+
@login_required
47+
def statistics():
48+
"""Displays site event statistics."""
49+
form = StatisticsParametersForm(formdata=request.args)
50+
if form.validate():
51+
if form.activity_id.data == form.ALL_ACTIVITIES:
52+
engine = StatisticsEngine(year=form.year.data)
53+
else:
54+
engine = StatisticsEngine(
55+
activity_id=form.activity_id.data, year=form.year.data
56+
)
57+
else:
58+
engine = StatisticsEngine(year=StatisticsParametersForm().year.data)
59+
60+
if "excel" in request.args:
61+
return send_file(
62+
engine.export_excel(),
63+
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
64+
download_name="Statistiques Collectives.xlsx",
65+
as_attachment=True,
66+
)
67+
68+
return render_template("stats/stats.html", engine=engine, form=form)

collectives/static/css/components/_index.scss

+1
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@
1717
@import 'card';
1818
@import 'usericon';
1919
@import 'technician-configuration';
20+
@import 'stats';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
.card {
2+
position: relative;
3+
border-radius: 6px;
4+
box-shadow: 0 .5em 1em -.125em rgba(10,10,10,.1),0 0 0 1px rgba(10,10,10,.02);
5+
color: #4a4a4a;
6+
display: block;
7+
padding: 1.25rem;
8+
border: 0.5px solid #e6e6e6;
9+
10+
.tooltip{
11+
position: absolute;
12+
bottom: 5px;
13+
right: 10px;
14+
15+
.text {
16+
visibility: hidden;
17+
min-width: 300px;
18+
background-color: rgb(51, 51, 51);
19+
color: #fff;
20+
text-align: justify;
21+
padding: 10px;
22+
border-radius: 6px;
23+
position: absolute;
24+
z-index: 1;
25+
right: 105%;
26+
}
27+
28+
img{
29+
height: 1.5em;
30+
width: 1.5em;
31+
opacity: 0.75;
32+
}
33+
}
34+
.tooltip:hover .text, .tooltip:active .text {
35+
visibility: visible;
36+
}
37+
}
38+
39+
.card.single-stat{
40+
text-align: center;
41+
42+
.value{
43+
font-size: 4em;
44+
font-weight: bold;
45+
}
46+
47+
.header-3{
48+
font-weight: normal;
49+
}
50+
}

collectives/templates/partials/main-navigation.html

+8
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@
2929
</a>
3030
</li>
3131

32+
{# Deactivated : awaiting test
33+
<li class="menu-dropdown-item">
34+
<a href="{{ url_for('root.statistics') }}" class="menu-dropdown-item-link">
35+
<img src="{{ url_for('static', filename='img/icon/ionicon/analytics-sharp.svg') }}" class="legacy-icon" />
36+
Statistiques
37+
</a>
38+
</li> #}
39+
3240
{% if current_user.can_create_events() %}
3341
<li class="menu-dropdown-item">
3442
<a href="{{ url_for('event.manage_event')}}" class="menu-dropdown-item-link"><img
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<div class="heading-3">{{ engine.INDEX['mean_collectives_per_day']['name'] }}</div>
2+
<div class="value">{% if engine.mean_collectives_per_day() %}{{engine.mean_collectives_per_day() | round(2)}}{%else%}-{% endif %}</div>
3+
<div class="tooltip">
4+
<img src="{{ url_for('static', filename='img/icon/ionicon/md-information-circle-outline.svg') }}" alt="(I)" />
5+
<span class="text">{{ engine.INDEX['mean_collectives_per_day']['description'] }}</span>
6+
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<div class="heading-3">{{ engine.INDEX['mean_events_per_day']['name'] }}</div>
2+
<div class="value">{% if engine.mean_events_per_day() %}{{engine.mean_events_per_day() | round(2)}}{%else%}-{% endif %}</div>
3+
<div class="tooltip">
4+
<img src="{{ url_for('static', filename='img/icon/ionicon/md-information-circle-outline.svg') }}" alt="(I)" />
5+
<span class="text">{{ engine.INDEX['mean_events_per_day']['description'] }}</span>
6+
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<div class="heading-3">{{ engine.INDEX['mean_registrations_per_day']['name'] }}</div>
2+
<div class="value">{% if engine.mean_registrations_per_day() %}{{engine.mean_registrations_per_day() | round(2)}}{%else%}-{% endif %}</div>
3+
<div class="tooltip">
4+
<img src="{{ url_for('static', filename='img/icon/ionicon/md-information-circle-outline.svg') }}" alt="(I)" />
5+
<span class="text">{{ engine.INDEX['mean_registrations_per_day']['description'] }}</span>
6+
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<div class="heading-3">{{ engine.INDEX['mean_registrations_per_event']['name'] }}</div>
2+
<div class="value">{{engine.mean_registrations_per_event() | round(2)}}</div>
3+
<div class="tooltip">
4+
<img src="{{ url_for('static', filename='img/icon/ionicon/md-information-circle-outline.svg') }}" alt="(I)" />
5+
<span class="text">{{ engine.INDEX['mean_registrations_per_event']['description'] }}</span>
6+
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
2+
<div class="heading-3">{{ engine.INDEX['nb_active_registrations']['name'] }}</div>
3+
<div class="value">{{engine.nb_active_registrations()}}</div>
4+
<div class="tooltip">
5+
<img src="{{ url_for('static', filename='img/icon/ionicon/md-information-circle-outline.svg') }}" alt="(I)" />
6+
<span class="text">{{ engine.INDEX['nb_active_registrations']['description'] }}</span>
7+
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<div class="heading-3">Nombre d'évènements</div>
2+
<div class="value">{{engine.nb_collectives()}}</div>
3+
<div class="tooltip">
4+
<img src="{{ url_for('static', filename='img/icon/ionicon/md-information-circle-outline.svg') }}" alt="(I)" />
5+
<span class="text">{{ engine.INDEX['nb_collectives']['description'] }}</span>
6+
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<canvas id="collectives-activity-chart"></canvas>
2+
3+
<div class="tooltip">
4+
<img src="{{ url_for('static', filename='img/icon/ionicon/md-information-circle-outline.svg') }}" alt="(I)" />
5+
<span class="text">{{ engine.INDEX['nb_collectives_by_activity_type']['description'] }}</span>
6+
</div>
7+
8+
<script>
9+
new Chart(document.getElementById('collectives-activity-chart'), {
10+
type: 'bar',
11+
data: {
12+
labels: {{ engine.nb_collectives_by_activity_type().keys() | list | tojson | safe }},
13+
datasets: [{
14+
data: {{ engine.nb_collectives_by_activity_type().values() | list | tojson|safe }},
15+
}]
16+
},
17+
options: {
18+
aspectRatio: 3,
19+
plugins: {
20+
title: {
21+
text: "{{ engine.INDEX['nb_collectives_by_activity_type']['name'] | safe }}",
22+
}
23+
}, scales: {y:{grace:"15%"}}
24+
}
25+
});
26+
</script>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<div class="heading-3">Nombre d'évènements</div>
2+
<div class="value">{{engine.nb_events()}}</div>
3+
<div class="tooltip">
4+
<img src="{{ url_for('static', filename='img/icon/ionicon/md-information-circle-outline.svg') }}" alt="(I)" />
5+
<span class="text">{{ engine.INDEX['nb_events']['description'] }}</span>
6+
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<canvas id="activity-chart"></canvas>
2+
3+
<div class="tooltip">
4+
<img src="{{ url_for('static', filename='img/icon/ionicon/md-information-circle-outline.svg') }}" alt="(I)" />
5+
<span class="text">{{ engine.INDEX['nb_events_by_activity_type']['description'] }}</span>
6+
</div>
7+
8+
<script>
9+
new Chart(document.getElementById('activity-chart'), {
10+
type: 'bar',
11+
data: {
12+
labels: {{ engine.nb_events_by_activity_type().keys() | list | tojson | safe }},
13+
datasets: [{
14+
data: {{ engine.nb_events_by_activity_type().values() | list | tojson|safe }},
15+
}]
16+
},
17+
options: {
18+
aspectRatio: 3,
19+
plugins: {
20+
title: {
21+
text: "{{ engine.INDEX['nb_events_by_activity_type']['name'] | safe }}",
22+
}
23+
}, scales: {y:{grace:"15%"}}
24+
}
25+
});
26+
</script>

0 commit comments

Comments
 (0)