Compare commits

..

134 Commits

Author SHA1 Message Date
dependabot[bot]
67f8543ac7 [UPDATE] Update pillow requirement
Updates the requirements on [pillow](https://github.com/python-pillow/Pillow) to permit the latest version.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/11.1.0...12.0.0)

---
updated-dependencies:
- dependency-name: pillow
  dependency-version: 12.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-20 08:19:35 +00:00
thomas girod
459edc1b6e Merge pull request #1212 from ae-utbm/fix-notification-invoice
fix: notification on invoice call update
2025-10-18 15:05:38 +02:00
a760a0b75d Merge pull request #1191 from ae-utbm/notifications
Add macro to refresh messages from htmx swap
2025-10-18 14:39:30 +02:00
imperosol
fc615e90b2 fix: notification on invoice call update 2025-10-18 14:35:19 +02:00
Sli
76eebaf54e Rename notification plugin import on alpine-index 2025-10-18 14:35:08 +02:00
thomas girod
9407f4b341 Merge pull request #1104 from ae-utbm/invoice_calls_validation
Invoice calls validation checkbox
2025-10-18 14:21:46 +02:00
imperosol
8bd82c9d7c Complete invoice call validation feature 2025-10-17 13:44:03 +02:00
Kenneth SOARES
957441ceb1 fix checkbox width 2025-10-17 13:40:06 +02:00
Kenneth SOARES
3bcd417ad0 Basic implementation of invoice call validation 2025-10-17 13:40:05 +02:00
thomas girod
453e13d54b Merge pull request #1174 from ae-utbm/auto-archive
Automatic product actions
2025-10-16 09:16:50 +02:00
thomas girod
dbd86b66cc Merge pull request #1178 from ae-utbm/cache-photos
Cache user photos
2025-10-12 14:04:30 +02:00
thomas girod
dcf799b352 Merge pull request #1197 from ae-utbm/fix-permission
fix: permission in ClubAddMemberForm
2025-10-12 14:04:03 +02:00
imperosol
d815f7da97 fix: permission in ClubAddMemberForm 2025-10-10 21:20:04 +02:00
imperosol
dac52db434 forbid past dates for product actions 2025-10-10 20:50:50 +02:00
imperosol
f398c9901c fix: 500 on product create view 2025-10-10 20:42:36 +02:00
imperosol
5b91fe2145 use ModelFormSet instead of FormSet for scheduled actions 2025-10-10 20:40:44 +02:00
imperosol
abd905c24d write tests 2025-10-10 20:40:44 +02:00
imperosol
42b53a39f3 feat: automatic product counters edition 2025-10-10 20:40:44 +02:00
imperosol
5306001f6f ScheduledProductAction model to store tasks related to products 2025-10-10 20:40:44 +02:00
imperosol
83a4ac2a7e feat: automatic product archiving 2025-10-10 20:40:44 +02:00
thomas girod
30fd4f6926 Merge pull request #1054 from ae-utbm/edt
Embed the timetable generator in the sith
2025-10-10 20:39:43 +02:00
Noa Fouich
1b1ef18531 Merge pull request #1195 from ae-utbm/fix-css-on-barman-click-on-phone
fix css on barman click on phone
2025-10-06 16:36:18 +02:00
Noa Fouich
bcf5d30d8f fix css on barman click on phone 2025-10-06 16:13:51 +02:00
thomas girod
4b44e50780 Merge pull request #1193 from ae-utbm/optimize-jinja
Optimisations
2025-10-02 19:05:03 +02:00
imperosol
40c3276c3c remove spaces from autocomplete selects 2025-09-29 17:43:50 +02:00
imperosol
543a424258 fix: N+1 on news list for admins 2025-09-29 16:10:50 +02:00
imperosol
8ff25e6034 optimize main page notifications 2025-09-29 08:45:56 +02:00
Sli
fa8772ede2 Add macro to refresh messages from htmx swap 2025-09-27 19:49:17 +02:00
thomas girod
03f53e921b Merge pull request #1192 from ae-utbm/fix-add-member
fix: wrong text on member form submit button
2025-09-27 18:01:10 +02:00
imperosol
56f09fd739 fix: wrong text on member form submit button 2025-09-27 17:40:18 +02:00
thomas girod
19e3fc604d Merge pull request #1172 from ae-utbm/htmx-club
HTMXify club members page
2025-09-27 17:29:16 +02:00
imperosol
24e1ad6dc8 apply review comments 2025-09-27 17:06:43 +02:00
imperosol
2a30f30a31 feat: cache user pictures 2025-09-26 22:44:26 +02:00
imperosol
80545e682b add hour indicator 2025-09-26 22:32:51 +02:00
imperosol
a7adb4bba3 add translations 2025-09-26 22:32:49 +02:00
imperosol
e75e7e697a display course type on top left of slots 2025-09-26 22:32:35 +02:00
imperosol
9d99976bee add timetable to common links 2025-09-26 22:32:35 +02:00
imperosol
4103dce1bb simplify timetable generator url 2025-09-26 22:32:35 +02:00
Kenneth SOARES
126fcbaaa1 update regex 2025-09-26 22:32:35 +02:00
Kenneth SOARES
8a27214801 add colors to each subject 2025-09-26 22:32:35 +02:00
imperosol
e82f3649e5 allow export to Png 2025-09-26 22:32:35 +02:00
imperosol
d3444f6bea timetable base 2025-09-26 22:32:35 +02:00
Bartuccio Antoine
289ffe1109 Merge pull request #1190 from ae-utbm/alpine-notifications
Add alpine notifications plugin
2025-09-26 18:29:04 +02:00
imperosol
eadf74604c Split ClubMemberForm into JoinClubForm and ClubAddMemberForm 2025-09-26 18:23:49 +02:00
imperosol
cc58479a19 use new notifications system 2025-09-26 16:00:31 +02:00
imperosol
c03b6e5d9d add tests 2025-09-26 15:49:36 +02:00
imperosol
66cf2bd957 Better management of roles in ClubMemberForm 2025-09-26 15:49:33 +02:00
imperosol
3e8f3b9275 feat: success message on membership creation 2025-09-26 15:49:24 +02:00
imperosol
c7363de44f improve new member form style 2025-09-26 15:49:24 +02:00
imperosol
966fe0ec0e fix: N+1 queries on old club members view 2025-09-26 15:49:24 +02:00
imperosol
fd0af3a804 HTMXify club members page 2025-09-26 15:49:24 +02:00
imperosol
7db66bb8f6 feat: MembershipQuerySet.editable_by method 2025-09-26 15:49:24 +02:00
thomas girod
ff5bb04af1 Merge pull request #1188 from ae-utbm/autocomplete-sas
Clear tom select text when identifying users in SAS
2025-09-26 15:48:24 +02:00
Sli
ca50e5dc81 Add alpine notifications plugin 2025-09-26 14:54:26 +02:00
Bartuccio Antoine
f015bde768 Merge pull request #1186 from ae-utbm/jquery
Remove JQuery
2025-09-26 14:36:02 +02:00
Sli
bb09fd0feb Apply review comments 2025-09-26 14:33:17 +02:00
Sli
210278440a Change notification zone position 2025-09-26 13:36:36 +02:00
Sli
e041da9cf4 Remove unnecessary complex anonymous callback on poster list 2025-09-25 22:07:29 +02:00
Sli
54c1957776 Move notifications from eboutic checkout to billing info fragment 2025-09-25 16:02:56 +02:00
Sli
30356d97f3 Use SuccessMessageMixin on trombi 2025-09-25 16:02:56 +02:00
Sli
7eaf25a64f Remove QuikNotifMixin 2025-09-25 16:02:56 +02:00
Sli
c6e86841b3 Remove jquery remeanants 2025-09-25 16:02:56 +02:00
Sli
cbe9887efb Create unified notification system 2025-09-25 16:02:55 +02:00
Noa Fouich
980952807a Merge pull request #1189 from ae-utbm/deleted_barman_user_fix
Deleted barman user fix
2025-09-25 16:01:36 +02:00
Noa Fouich
0b7c516f18 adding test 2025-09-25 15:57:21 +02:00
Noa Fouich
e186052283 Fix deleted barman on user account
# Conflicts:
#	locale/fr/LC_MESSAGES/django.po
2025-09-25 15:57:16 +02:00
imperosol
ec80b72a25 clear tom select text when identifying users in SAS 2025-09-25 07:38:44 +02:00
Bartuccio Antoine
6cd3875b2b Merge pull request #1187 from ae-utbm/fix-search
Remove `s` shortcut for search bar
2025-09-24 18:09:00 +02:00
Sli
ad8b003336 Remove s shortcut for search bar 2025-09-24 16:36:55 +02:00
Bartuccio Antoine
b4f5a866e3 Merge pull request #1185 from ae-utbm/posters
Remove jquery from posters
2025-09-23 14:59:24 +02:00
Sli
d87b069769 Apply review comments 2025-09-23 10:28:05 +02:00
thomas girod
9461b2e5d9 Merge pull request #1184 from ae-utbm/page-N+1
fix: N+1 query on PageListView
2025-09-23 09:18:24 +02:00
Sli
4701c0804b Fix slideshow transition 2025-09-22 23:06:18 +02:00
imperosol
acb6c6ce9c fix: N+1 query on PageListView 2025-09-22 18:14:14 +02:00
Sli
95e6fff98b Migrate poster view to alpine 2025-09-22 14:30:23 +02:00
thomas girod
f1a5a0781c Merge pull request #1181 from ae-utbm/fix-subscription
Fix subscription
2025-09-22 13:41:15 +02:00
imperosol
854dd2d9e7 add disclaimer for subscription purchase with AE account 2025-09-22 13:28:42 +02:00
imperosol
a7c96425c8 fix: ClubSellingView N+1 queries 2025-09-22 13:28:42 +02:00
Sli
dff23fae7f Migrate slideshow to alpine 2025-09-22 13:26:28 +02:00
thomas girod
34b0dc3302 Merge pull request #1182 from ae-utbm/fix-pagerev
fix: 500 on page properties edit
2025-09-22 13:04:22 +02:00
thomas girod
31aee01360 Merge pull request #1169 from ae-utbm/dependabot/npm_and_yarn/vite-6.3.6
Bump vite from 6.3.5 to 6.3.6
2025-09-21 16:05:03 +02:00
imperosol
ce2ef78a6d fix: 500 on page properties edit 2025-09-21 16:01:17 +02:00
Kenneth Soares
f7c5088048 Merge pull request #1177 from ae-utbm/fix_archived_products
Fix display of archived products
2025-09-19 20:09:40 +02:00
thomas girod
9bc6a447b9 Merge pull request #1179 from ae-utbm/poster-access
Make poster views available to club board members
2025-09-19 19:54:32 +02:00
imperosol
08b16d6e74 feat: make poster views available to club board members 2025-09-19 17:22:44 +02:00
thomas girod
c6baab068a Merge pull request #1164 from ae-utbm/subscription-birthday
Subscription birthday
2025-09-19 12:58:03 +02:00
Noa Fouich
262281adda Add test case 2025-09-18 14:40:20 +02:00
thomas girod
b58eca3ed0 Merge pull request #1171 from ae-utbm/club-edit-groups
fix: `Counter.edit_groups`
2025-09-16 15:20:47 +02:00
Kenneth SOARES
c7fe8961ab fixed display of archived products 2025-09-16 12:43:03 +02:00
thomas girod
18f77ef2cb Merge pull request #1176 from ae-utbm/fix-dependabot
Fix dependabot
2025-09-16 09:04:02 +02:00
imperosol
b58da0ea30 fix: dependabot.yml 2025-09-15 12:04:18 +02:00
imperosol
25cd877160 fix: Counter.edit_groups 2025-09-13 11:39:53 +02:00
dependabot[bot]
79297b7a75 Bump vite from 6.3.5 to 6.3.6
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.3.5 to 6.3.6.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.3.6/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.3.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.3.6
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-10 02:04:00 +00:00
thomas girod
b767079c5a Merge pull request #1167 from ae-utbm/page-n+1
Page n+1
2025-09-08 11:28:55 +02:00
imperosol
37961e437b fix: N+1 queries on PageListView 2025-09-04 17:39:17 +02:00
imperosol
b97a1a2e56 improve User.can_view and User.can_edit 2025-09-04 17:38:58 +02:00
imperosol
3ad40b7383 change birthdate only if user didn't have it previously 2025-09-04 11:03:02 +02:00
imperosol
3709b5c221 require birthday when creating subscriptions for users that didn't give it previously 2025-09-04 11:02:59 +02:00
imperosol
171a3f4d92 make some users not having birthday in populate_more.py 2025-09-04 11:02:48 +02:00
imperosol
84e2f1b45a fix: subscription form alignment 2025-09-04 11:02:48 +02:00
thomas girod
fdf5e4fbe9 Merge pull request #1161 from ae-utbm/meta-tags
Meta tags
2025-09-04 10:51:25 +02:00
thomas girod
4e08591721 Merge pull request #1163 from ae-utbm/sitemap
Add sitemap
2025-09-04 10:51:10 +02:00
thomas girod
27b98f4a48 Merge pull request #1166 from ae-utbm/com-notification
Com notification
2025-09-03 14:40:06 +02:00
Kenneth Soares
e0702ce8be Merge pull request #1165 from ae-utbm/taiste
Commands, Galaxy, Buxfixes and other
2025-09-03 14:32:30 +02:00
imperosol
cb454935ad fix: N+1 queries on ICS generation 2025-09-03 14:00:09 +02:00
imperosol
17c50934bb fix: news notifications
Résout trois problèmes :
- la création des notifications faisait un N+1 queries
- le décompte du nombre de nouvelles à modérer était mauvais
- modérer une nouvelle ne modifiait pas les notifications des autres admins
2025-09-03 13:55:07 +02:00
imperosol
5646f22968 feat: add sitemap 2025-09-02 16:00:03 +02:00
thomas girod
cf3daa2574 Merge pull request #1160 from ae-utbm/fix-old-subscribers-group
fix old subscribers group attribution
2025-09-01 20:10:37 +02:00
imperosol
03759fd83e fix translations 2025-09-01 18:21:55 +02:00
imperosol
83c96884d8 add missing meta description tags 2025-09-01 18:20:27 +02:00
imperosol
8524996f06 simplify Subscription.save() 2025-09-01 15:30:39 +02:00
thomas girod
57e3a930ba Merge pull request #1136 from ae-utbm/galaxy
Optimize galaxy generation
2025-09-01 14:18:02 +02:00
imperosol
2086d23b50 fix old subscribers group attribution
Si un utilisateur faisait sa première cotisation alors qu'il avait déjà un compte AE (par exemple, en effectuant un achat sur l'eboutic avant sa cotisation), alors il pouvait se retrouver hors du groupe Anciens cotisants.
2025-08-31 20:49:56 +02:00
imperosol
d8f907fc70 Optimize galaxy generation
En réorganisant les requêtes à la db, on diminue par 100 le temps d'exécution de la commande `rule_galaxy` (~6h => ~2min)
2025-08-30 19:05:41 +02:00
Bartuccio Antoine
81260b34a2 Merge pull request #1159 from ae-utbm/update
Update dependencies
2025-08-29 08:16:56 +02:00
Bartuccio Antoine
7bd3f69c76 Merge pull request #1158 from ae-utbm/dependabot-config
Update dependabot config
2025-08-29 08:16:31 +02:00
thomas girod
257ad0f7e4 Merge pull request #1157 from ae-utbm/checkconstraint
replace deprecated CheckConstraint.check by CheckConstraint.condition
2025-08-29 00:45:41 +02:00
Sli
f3fe67cf75 Update dependencies 2025-08-28 23:42:06 +02:00
Sli
142dd6a16f Update dependabot config 2025-08-28 22:06:35 +02:00
imperosol
e864e82573 replace deprecated CheckConstraint.check by CheckConstraint.condition 2025-08-28 16:31:54 +02:00
Kenneth Soares
95b476b212 Merge pull request #1072 from ae-utbm/promo_add_tool
custom django command for promo logos
2025-08-27 21:00:22 +02:00
Bartuccio Antoine
0e9c470f41 Merge pull request #1155 from ae-utbm/eboutic
Fix auto basket cleaning after refilling account
2025-08-26 19:09:49 +02:00
Sli
ed9c718cf1 Apply review comments 2025-08-26 10:30:08 +02:00
Sli
25099528bf Improve eboutic readability 2025-08-24 00:26:34 +02:00
Sli
0bc18be75e Add basket cleaning tests 2025-08-23 15:16:57 +02:00
Sli
f44fe72423 Get customer last purchases in one request 2025-08-23 15:09:05 +02:00
Sli
c016dbc8bc Fix auto basket cleaning after refilling account 2025-08-22 10:36:57 +02:00
Kenneth SOARES
5b57f75b4e custom django command for promo logos
added path vailidity verification and IOError handling

added option to overwrite existing logo and force flag

improved uppon suggestions

mistake correction

fixed string conversion bugs and logical error

corrected path conversion

f

better error handling and corrections

ajout d'une section de documentation pour la feature

copié coller

fixed documentation bullet points

added resampling clean up error handling

removed useless IOError
2025-07-03 14:28:16 +02:00
thomas girod
f6683068ff Merge pull request #1147 from ae-utbm/taiste
Many fixes
2025-07-02 10:10:19 +02:00
thomas girod
81d1d1caca Merge pull request #1128 from ae-utbm/taiste
Api keys, better tabs, navbar and accordions, better notifications, fixes and dependencies updates
2025-06-17 14:08:05 +02:00
thomas girod
1cc2378476 Merge pull request #1112 from ae-utbm/taiste
Accordions, navbar and fixes
2025-06-05 19:51:13 +02:00
thomas girod
61e370cf73 Merge pull request #1107 from ae-utbm/taiste
Eboutic refactor, Celery, better tooltips, Python 3.13, bugfixes and other
2025-06-03 00:03:33 +02:00
thomas girod
6377acfffa Merge pull request #1084 from ae-utbm/taiste
Django 5.2, HTMX for billing infos form, eurocks widget consent message and new promo 24 logo
2025-04-14 12:42:19 +02:00
thomas girod
3c8933461a Merge pull request #1075 from ae-utbm/taiste
SAS and markdown pictures upload improval, google calendar removal, calendar export link, css fixes and more
2025-04-10 13:15:02 +02:00
161 changed files with 4722 additions and 4194 deletions

View File

@@ -6,7 +6,7 @@ addAssignees: author
# A list of team reviewers to be added to pull requests (GitHub team slug) # A list of team reviewers to be added to pull requests (GitHub team slug)
reviewers: reviewers:
- ae-utbm/sith-3-developers - ae-utbm/developpeurs
# Number of reviewers has no impact on GitHub teams # Number of reviewers has no impact on GitHub teams
# Set 0 to add all the reviewers (default: 0) # Set 0 to add all the reviewers (default: 0)

View File

@@ -4,11 +4,28 @@
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2 version: 2
updates:
- package-ecosystem: "pip" # See documentation for possible values multi-ecosystem-groups:
directory: "/" # Location of package manifests common:
directory: "/"
schedule: schedule:
interval: "weekly" interval: "weekly"
target-branch: "taiste" target-branch: "taiste"
commit-message: commit-message:
prefix: "[UPDATE] " prefix: "[UPDATE] "
updates:
- package-ecosystem: "uv"
patterns: ["*"]
multi-ecosystem-group: "common"
- package-ecosystem: "npm"
patterns: ["*"]
multi-ecosystem-group: "common"
groups:
# npm supports production and development groups, but not uv
# cf. https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#dependency-type-groups
main-deps:
dependency-type: "production"
dev-deps:
dependency-type: "development"

View File

@@ -26,12 +26,16 @@ from django import forms
from django.conf import settings from django.conf import settings
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, OuterRef, Q
from django.db.models.functions import Lower from django.db.models.functions import Lower
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from club.models import Club, Mailing, MailingSubscription, Membership from club.models import Club, Mailing, MailingSubscription, Membership
from core.models import User from core.models import User
from core.views.forms import SelectDate, SelectDateTime from core.views.forms import SelectDateTime
from core.views.widgets.ajax_select import AutoCompleteSelectMultipleUser from core.views.widgets.ajax_select import (
AutoCompleteSelectMultipleUser,
AutoCompleteSelectUser,
)
from counter.models import Counter, Selling from counter.models import Counter, Selling
@@ -188,70 +192,81 @@ class SellingsForm(forms.Form):
) )
class ClubMemberForm(forms.Form): class ClubOldMemberForm(forms.Form):
"""Form handling the members of a club.""" members_old = forms.ModelMultipleChoiceField(
Membership.objects.none(),
label=_("Mark as old"),
widget=forms.CheckboxSelectMultiple,
required=False,
)
def __init__(self, *args, user: User, club: Club, **kwargs):
super().__init__(*args, **kwargs)
self.fields["members_old"].queryset = (
Membership.objects.ongoing().filter(club=club).editable_by(user)
)
class ClubMemberForm(forms.ModelForm):
"""Form to add a member to the club, as a board member."""
error_css_class = "error" error_css_class = "error"
required_css_class = "required" required_css_class = "required"
users = forms.ModelMultipleChoiceField( class Meta:
label=_("Users to add"), model = Membership
help_text=_("Search users to add (one or more)."), fields = ["role", "description"]
required=False,
widget=AutoCompleteSelectMultipleUser,
queryset=User.objects.all(),
)
def __init__(self, *args, **kwargs): def __init__(self, *args, club: Club, request_user: User, **kwargs):
self.club = kwargs.pop("club") self.club = club
self.request_user = kwargs.pop("request_user") self.request_user = request_user
self.club_members = kwargs.pop("club_members", None)
if not self.club_members:
self.club_members = self.club.members.ongoing().order_by("-role").all()
self.request_user_membership = self.club.get_membership_for(self.request_user) self.request_user_membership = self.club.get_membership_for(self.request_user)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["role"].required = True
# Using a ModelForm binds too much the form with the model and we don't want that self.fields["role"].choices = [
# We want the view to process the model creation since they are multiple users (value, name)
# We also want the form to handle bulk deletion for value, name in settings.SITH_CLUB_ROLES.items()
self.fields.update( if value <= self.max_available_role
forms.fields_for_model(
Membership,
fields=("role", "start_date", "description"),
widgets={"start_date": SelectDate},
)
)
# Role is required only if users is specified
self.fields["role"].required = False
# Start date and description are never really required
self.fields["start_date"].required = False
self.fields["description"].required = False
self.fields["users_old"] = forms.ModelMultipleChoiceField(
User.objects.filter(
id__in=[
ms.user.id
for ms in self.club_members
if ms.can_be_edited_by(self.request_user)
] ]
).all(), self.instance.club = club
label=_("Mark as old"),
required=False,
widget=forms.CheckboxSelectMultiple,
)
if not self.request_user.is_root:
self.fields.pop("start_date")
def clean_users(self): @property
"""Check that the user is not trying to add an user already in the club. def max_available_role(self):
"""The greatest role that will be obtainable with this form."""
# this is unreachable, because it will be overridden by subclasses
return -1 # pragma: no cover
class ClubAddMemberForm(ClubMemberForm):
"""Form to add a member to the club, as a board member."""
class Meta(ClubMemberForm.Meta):
fields = ["user", *ClubMemberForm.Meta.fields]
widgets = {"user": AutoCompleteSelectUser}
@cached_property
def max_available_role(self):
"""The greatest role that will be obtainable with this form.
Admins and the club president can attribute any role.
Board members can attribute roles lower than their own.
Other users cannot attribute roles with this form
"""
if self.request_user.has_perm("club.add_membership"):
return settings.SITH_CLUB_ROLES_ID["President"]
membership = self.request_user_membership
if membership is None or membership.role <= settings.SITH_MAXIMUM_FREE_ROLE:
return -1
if membership.role == settings.SITH_CLUB_ROLES_ID["President"]:
return membership.role
return membership.role - 1
def clean_user(self):
"""Check that the user is not trying to add a user already in the club.
Also check that the user is valid and has a valid subscription. Also check that the user is valid and has a valid subscription.
""" """
cleaned_data = super().clean() user = self.cleaned_data["user"]
users = []
for user in cleaned_data["users"]:
if not user.is_subscribed: if not user.is_subscribed:
raise forms.ValidationError( raise forms.ValidationError(
_("User must be subscriber to take part to a club"), code="invalid" _("User must be subscriber to take part to a club"), code="invalid"
@@ -260,33 +275,30 @@ class ClubMemberForm(forms.Form):
raise forms.ValidationError( raise forms.ValidationError(
_("You can not add the same user twice"), code="invalid" _("You can not add the same user twice"), code="invalid"
) )
users.append(user) return user
return users
class JoinClubForm(ClubMemberForm):
"""Form to join a club."""
def __init__(self, *args, club: Club, request_user: User, **kwargs):
super().__init__(*args, club=club, request_user=request_user, **kwargs)
# this form doesn't manage the user who will join the club,
# so we must set this here to avoid errors
self.instance.user = self.request_user
@cached_property
def max_available_role(self):
return settings.SITH_MAXIMUM_FREE_ROLE
def clean(self): def clean(self):
"""Check user rights for adding an user.""" """Check that the user is subscribed and isn't already in the club."""
cleaned_data = super().clean() if not self.request_user.is_subscribed:
raise forms.ValidationError(
if "start_date" in cleaned_data and not cleaned_data["start_date"]: _("You must be subscribed to join a club"), code="invalid"
# Drop start_date if allowed to edition but not specified )
cleaned_data.pop("start_date") if self.club.get_membership_for(self.request_user):
raise forms.ValidationError(
if not cleaned_data.get("users"): _("You are already a member of this club"), code="invalid"
# No user to add equals no check needed )
return cleaned_data return super().clean()
if cleaned_data.get("role", "") == "":
# Role is required if users exists
self.add_error("role", _("You should specify a role"))
return cleaned_data
request_user = self.request_user
membership = self.request_user_membership
if not (
cleaned_data["role"] <= settings.SITH_MAXIMUM_FREE_ROLE
or (membership is not None and membership.role >= cleaned_data["role"])
or request_user.is_board_member
or request_user.is_root
):
raise forms.ValidationError(_("You do not have the permission to do that"))
return cleaned_data

View File

@@ -34,12 +34,10 @@ def migrate_meta_groups(apps: StateApps, schema_editor):
clubs = list(Club.objects.all()) clubs = list(Club.objects.all())
for club in clubs: for club in clubs:
club.board_group = meta_groups.get_or_create( club.board_group = meta_groups.get_or_create(
name=club.unix_name + settings.SITH_BOARD_SUFFIX, name=f"{club.unix_name}-bureau", defaults={"is_meta": True}
defaults={"is_meta": True},
)[0] )[0]
club.members_group = meta_groups.get_or_create( club.members_group = meta_groups.get_or_create(
name=club.unix_name + settings.SITH_MEMBER_SUFFIX, name=f"{club.unix_name}-membres", defaults={"is_meta": True}
defaults={"is_meta": True},
)[0] )[0]
club.save() club.save()
club.refresh_from_db() club.refresh_from_db()

View File

@@ -29,7 +29,7 @@ class Migration(migrations.Migration):
migrations.AddConstraint( migrations.AddConstraint(
model_name="membership", model_name="membership",
constraint=models.CheckConstraint( constraint=models.CheckConstraint(
check=models.Q(("end_date__gte", models.F("start_date"))), condition=models.Q(("end_date__gte", models.F("start_date"))),
name="end_after_start", name="end_after_start",
), ),
), ),

View File

@@ -30,7 +30,8 @@ from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import RegexValidator, validate_email from django.core.validators import RegexValidator, validate_email
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Exists, F, OuterRef, Q from django.db.models import Exists, F, OuterRef, Q, Value
from django.db.models.functions import Greatest
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
@@ -42,6 +43,13 @@ from core.fields import ResizedImageField
from core.models import Group, Notification, Page, SithFile, User from core.models import Group, Notification, Page, SithFile, User
class ClubQuerySet(models.QuerySet):
def having_board_member(self, user: User) -> Self:
"""Filter all club in which the given user is a board member."""
active_memberships = user.memberships.board().ongoing()
return self.filter(Exists(active_memberships.filter(club=OuterRef("pk"))))
class Club(models.Model): class Club(models.Model):
"""The Club class, made as a tree to allow nice tidy organization.""" """The Club class, made as a tree to allow nice tidy organization."""
@@ -91,6 +99,8 @@ class Club(models.Model):
Group, related_name="club_board", on_delete=models.PROTECT Group, related_name="club_board", on_delete=models.PROTECT
) )
objects = ClubQuerySet.as_manager()
class Meta: class Meta:
ordering = ["name"] ordering = ["name"]
@@ -200,10 +210,6 @@ class Club(models.Model):
"""Method to see if that object can be edited by the given user.""" """Method to see if that object can be edited by the given user."""
return self.has_rights_in_club(user) return self.has_rights_in_club(user)
def can_be_viewed_by(self, user: User) -> bool:
"""Method to see if that object can be seen by the given user."""
return user.was_subscribed
def get_membership_for(self, user: User) -> Membership | None: def get_membership_for(self, user: User) -> Membership | None:
"""Return the current membership the given user. """Return the current membership the given user.
@@ -243,6 +249,44 @@ class MembershipQuerySet(models.QuerySet):
""" """
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE) return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
def editable_by(self, user: User) -> Self:
"""Filter Memberships that this user can edit.
Users with the `club.change_membership` permission can edit all Membership.
The other users can edit :
- their own membership
- if they are board members, ongoing memberships with a role lower than their own
For example, let's suppose the following users :
- A : board member
- B : board member
- C : simple member
- D : curious
- E : old member
A will be able to edit the memberships of A, C and D ;
C and D will be able to edit only their own membership ;
nobody will be able to edit E's membership.
"""
if user.has_perm("club.change_membership"):
return self.all()
return self.filter(
Q(user=user)
| Exists(
Membership.objects.filter(
Q(
role__gt=Greatest(
OuterRef("role"), Value(settings.SITH_MAXIMUM_FREE_ROLE)
)
),
user=user,
end_date=None,
club=OuterRef("club"),
)
),
end_date=None,
)
def update(self, **kwargs) -> int: def update(self, **kwargs) -> int:
"""Refresh the cache and edit group ownership. """Refresh the cache and edit group ownership.
@@ -319,16 +363,12 @@ class Membership(models.Model):
User, User,
verbose_name=_("user"), verbose_name=_("user"),
related_name="memberships", related_name="memberships",
null=False,
blank=False,
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
club = models.ForeignKey( club = models.ForeignKey(
Club, Club,
verbose_name=_("club"), verbose_name=_("club"),
related_name="members", related_name="members",
null=False,
blank=False,
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
start_date = models.DateField(_("start date"), default=timezone.now) start_date = models.DateField(_("start date"), default=timezone.now)
@@ -347,7 +387,7 @@ class Membership(models.Model):
class Meta: class Meta:
constraints = [ constraints = [
models.CheckConstraint( models.CheckConstraint(
check=Q(end_date__gte=F("start_date")), name="end_after_start" condition=Q(end_date__gte=F("start_date")), name="end_after_start"
), ),
] ]

View File

@@ -0,0 +1,24 @@
#club_members_table {
tbody label {
margin: 0;
padding: 0;
}
}
#add_club_members_form {
fieldset {
display: flex;
flex-direction: row;
column-gap: 2em;
row-gap: 1em;
flex-wrap: wrap;
@media (max-width: 1100px) {
justify-content: space-evenly;
}
.errorlist {
max-width: 300px;
}
}
}

View File

@@ -1,6 +1,14 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from 'core/macros.jinja' import user_profile_link %} {% from 'core/macros.jinja' import user_profile_link %}
{% block title -%}
{{ club.name }}
{%- endblock %}
{% block description -%}
{{ club.short_description }}
{%- endblock %}
{% block content %} {% block content %}
<div id="club_detail"> <div id="club_detail">
{% if club.logo %} {% if club.logo %}

View File

@@ -1,8 +1,12 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% block title %} {% block title -%}
{% trans %}Club list{% endtrans %} {% trans %}Club list{% endtrans %}
{% endblock %} {%- endblock %}
{% block description -%}
{% trans %}The list of all clubs existing at UTBM.{% endtrans %}
{%- endblock %}
{% macro display_club(club) -%} {% macro display_club(club) -%}
@@ -21,7 +25,7 @@
{%- if club.children.all()|length != 0 %} {%- if club.children.all()|length != 0 %}
<ul> <ul>
{%- for c in club.children.order_by('name') %} {%- for c in club.children.order_by('name').prefetch_related("children") %}
{{ display_club(c) }} {{ display_club(c) }}
{%- endfor %} {%- endfor %}
</ul> </ul>
@@ -36,8 +40,8 @@
{% if club_list %} {% if club_list %}
<h3>{% trans %}Club list{% endtrans %}</h3> <h3>{% trans %}Club list{% endtrans %}</h3>
<ul> <ul>
{%- for c in club_list.all().order_by('name') if c.parent is none %} {%- for club in club_list %}
{{ display_club(c) }} {{ display_club(club) }}
{%- endfor %} {%- endfor %}
</ul> </ul>
{% else %} {% else %}

View File

@@ -1,15 +1,33 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from 'core/macros.jinja' import user_profile_link, select_all_checkbox %} {% from 'core/macros.jinja' import user_profile_link, select_all_checkbox %}
{% block additional_js %}
<script type="module" src="{{ static("bundled/core/components/ajax-select-index.ts") }}"></script>
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
<link rel="stylesheet" href="{{ static("club/members.scss") }}">
{% endblock %}
{% block content %} {% block content %}
{% block notifications %}
{# Notifications are moved a little bit below #}
{% endblock %}
<h2>{% trans %}Club members{% endtrans %}</h2> <h2>{% trans %}Club members{% endtrans %}</h2>
{% if add_member_fragment %}
<br />
{{ add_member_fragment }}
<br />
{% endif %}
{% include "core/base/notifications.jinja" %}
{% if members %} {% if members %}
<form action="{{ url('club:club_members', club_id=club.id) }}" id="users_old" method="post"> <form action="{{ url('club:club_members', club_id=club.id) }}" id="members_old" method="post">
{% csrf_token %} {% csrf_token %}
{% set users_old = dict(form.users_old | groupby("choice_label")) %} {% if can_end_membership %}
{% if users_old %} {{ select_all_checkbox("members_old") }}
{{ select_all_checkbox("users_old") }} <br />
<p></p>
{% endif %} {% endif %}
<table id="club_members_table"> <table id="club_members_table">
<thead> <thead>
@@ -18,7 +36,7 @@
<td>{% trans %}Role{% endtrans %}</td> <td>{% trans %}Role{% endtrans %}</td>
<td>{% trans %}Description{% endtrans %}</td> <td>{% trans %}Description{% endtrans %}</td>
<td>{% trans %}Since{% endtrans %}</td> <td>{% trans %}Since{% endtrans %}</td>
{% if users_old %} {% if can_end_membership %}
<td>{% trans %}Mark as old{% endtrans %}</td> <td>{% trans %}Mark as old{% endtrans %}</td>
{% endif %} {% endif %}
</tr> </tr>
@@ -30,20 +48,24 @@
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td> <td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
<td>{{ m.description }}</td> <td>{{ m.description }}</td>
<td>{{ m.start_date }}</td> <td>{{ m.start_date }}</td>
{% if users_old %} {%- if can_end_membership -%}
<td> <td>
{% set user_old = users_old[m.user.get_display_name()] %} {%- if m.is_editable -%}
{% if user_old %} <label for="id_members_old_{{ loop.index }}"></label>
{{ user_old[0].tag() }} <input
{% endif %} type="checkbox"
name="members_old"
value="{{ m.id }}"
id="id_members_old_{{ loop.index }}"
>
{%- endif -%}
</td> </td>
{% endif %} {%- endif -%}
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{{ form.users_old.errors }} {% if can_end_membership %}
{% if users_old %}
<p></p> <p></p>
<input type="submit" name="submit" value="{% trans %}Mark as old{% endtrans %}"> <input type="submit" name="submit" value="{% trans %}Mark as old{% endtrans %}">
{% endif %} {% endif %}
@@ -51,32 +73,4 @@
{% else %} {% else %}
<p>{% trans %}There are no members in this club.{% endtrans %}</p> <p>{% trans %}There are no members in this club.{% endtrans %}</p>
{% endif %} {% endif %}
<form action="{{ url('club:club_members', club_id=club.id) }}" id="add_users" method="post">
{% csrf_token %}
{{ form.non_field_errors() }}
<p>
{{ form.users.errors }}
<label for="{{ form.users.id_for_label }}">{{ form.users.label }} :</label>
{{ form.users }}
<span class="helptext">{{ form.users.help_text }}</span>
</p>
<p>
{{ form.role.errors }}
<label for="{{ form.role.id_for_label }}">{{ form.role.label }} :</label>
{{ form.role }}
</p>
{% if form.start_date %}
<p>
{{ form.start_date.errors }}
<label for="{{ form.start_date.id_for_label }}">{{ form.start_date.label }} :</label>
{{ form.start_date }}
</p>
{% endif %}
<p>
{{ form.description.errors }}
<label for="{{ form.description.id_for_label }}">{{ form.description.label }} :</label>
{{ form.description }}
</p>
<p><input type="submit" value="{% trans %}Add{% endtrans %}" /></p>
</form>
{% endblock %} {% endblock %}

View File

@@ -5,20 +5,22 @@
<h2>{% trans %}Club old members{% endtrans %}</h2> <h2>{% trans %}Club old members{% endtrans %}</h2>
<table> <table>
<thead> <thead>
<tr>
<td>{% trans %}User{% endtrans %}</td> <td>{% trans %}User{% endtrans %}</td>
<td>{% trans %}Role{% endtrans %}</td> <td>{% trans %}Role{% endtrans %}</td>
<td>{% trans %}Description{% endtrans %}</td> <td>{% trans %}Description{% endtrans %}</td>
<td>{% trans %}From{% endtrans %}</td> <td>{% trans %}From{% endtrans %}</td>
<td>{% trans %}To{% endtrans %}</td> <td>{% trans %}To{% endtrans %}</td>
</tr>
</thead> </thead>
<tbody> <tbody>
{% for m in club.members.exclude(end_date=None).order_by('-role', 'description', '-end_date').all() %} {% for member in old_members %}
<tr> <tr>
<td>{{ user_profile_link(m.user) }}</td> <td>{{ user_profile_link(member.user) }}</td>
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td> <td>{{ settings.SITH_CLUB_ROLES[member.role] }}</td>
<td>{{ m.description }}</td> <td>{{ member.description }}</td>
<td>{{ m.start_date }}</td> <td>{{ member.start_date }}</td>
<td>{{ m.end_date }}</td> <td>{{ member.end_date }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@@ -83,9 +83,10 @@ TODO : rewrite the pagination used in this template an Alpine one
</table> </table>
<script type="text/javascript"> <script type="text/javascript">
function formPagination(link){ function formPagination(link){
$("form").attr("action", link.href); const form = document.getElementById("form")
form.action = link.href;
link.href = "javascript:void(0)"; // block link action link.href = "javascript:void(0)"; // block link action
$("form").submit(); form.submit();
} }
</script> </script>
{{ paginate(paginated_result, paginator, "formPagination(this)") }} {{ paginate(paginated_result, paginator, "formPagination(this)") }}

View File

@@ -0,0 +1,46 @@
<section id="member-fragment-container">
{% if form.user %}
<h4>{% trans %}Add a new member{% endtrans %}</h4>
{% else %}
<h4>{% trans %}Join club{% endtrans %}</h4>
{% endif %}
<form
hx-post="{{ url('club:club_new_members', club_id=club.id) }}"
hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML"
hx-target="#member-fragment-container"
id="add_club_members_form"
>
{% csrf_token %}
{{ form.non_field_errors() }}
<fieldset>
{% if form.user %}
<div>
{{ form.user.label_tag() }}
<span class="helptext">{{ form.user.help_text }}</span>
{{ form.user }}
{{ form.user.errors }}
</div>
{% endif %}
<div>
{{ form.role.label_tag() }}
{{ form.role }}
{{ form.role.errors }}
</div>
<div>
{{ form.description.label_tag() }}
{{ form.description }}
{{ form.description.errors }}
</div>
</fieldset>
<button type="submit" class="btn btn-blue">
<i class="fa fa-user-plus"></i>
{%- if form.user -%}
{% trans %}Add{% endtrans %}
{%- else -%}
{% trans %}Join{% endtrans %}
{%- endif -%}
</button>
</form>
</section>

View File

@@ -43,6 +43,9 @@ class TestClub(TestCase):
cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID) cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID)
cls.club = baker.make(Club) cls.club = baker.make(Club)
cls.new_members_url = reverse(
"club:club_new_members", kwargs={"club_id": cls.club.id}
)
cls.members_url = reverse("club:club_members", kwargs={"club_id": cls.club.id}) cls.members_url = reverse("club:club_members", kwargs={"club_id": cls.club.id})
a_month_ago = now() - timedelta(days=30) a_month_ago = now() - timedelta(days=30)
yesterday = now() - timedelta(days=1) yesterday = now() - timedelta(days=1)

27
club/tests/test_club.py Normal file
View File

@@ -0,0 +1,27 @@
from datetime import timedelta
import pytest
from django.utils.timezone import localdate
from model_bakery import baker
from model_bakery.recipe import Recipe
from club.models import Club, Membership
from core.baker_recipes import subscriber_user
@pytest.mark.django_db
def test_club_queryset_having_board_member():
clubs = baker.make(Club, _quantity=5)
user = subscriber_user.make()
membership_recipe = Recipe(
Membership, user=user, start_date=localdate() - timedelta(days=3)
)
membership_recipe.make(club=clubs[0], role=1)
membership_recipe.make(club=clubs[1], role=3)
membership_recipe.make(club=clubs[2], role=7)
membership_recipe.make(
club=clubs[3], role=3, end_date=localdate() - timedelta(days=1)
)
club_ids = Club.objects.having_board_member(user).values_list("id", flat=True)
assert set(club_ids) == {clubs[1].id, clubs[2].id}

View File

@@ -1,13 +1,20 @@
from collections.abc import Callable
from datetime import timedelta
import pytest
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission
from django.core.cache import cache from django.core.cache import cache
from django.db.models import Max from django.db.models import Max
from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import localdate, localtime, now from django.utils.timezone import localdate, localtime, now
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertRedirects
from club.forms import ClubMemberForm from club.forms import ClubAddMemberForm, JoinClubForm
from club.models import Membership from club.models import Club, Membership
from club.tests.base import TestClub from club.tests.base import TestClub
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, User from core.models import AnonymousUser, User
@@ -137,6 +144,38 @@ class TestMembershipQuerySet(TestClub):
assert set(user.groups.all()).isdisjoint(club_groups) assert set(user.groups.all()).isdisjoint(club_groups)
class TestMembershipEditableBy(TestCase):
@classmethod
def setUpTestData(cls):
Membership.objects.all().delete()
cls.club_a, cls.club_b = baker.make(Club, _quantity=2)
cls.memberships = [
*baker.make(
Membership, role=iter([7, 3, 3, 1]), club=cls.club_a, _quantity=4
),
*baker.make(
Membership, role=iter([7, 3, 3, 1]), club=cls.club_b, _quantity=4
),
]
def test_admin_user(self):
perm = Permission.objects.get(codename="change_membership")
user = baker.make(User, user_permissions=[perm])
qs = Membership.objects.editable_by(user).values_list("id", flat=True)
assert set(qs) == set(Membership.objects.values_list("id", flat=True))
def test_simple_subscriber_user(self):
user = subscriber_user.make()
assert not Membership.objects.editable_by(user).exists()
def test_board_member(self):
# a board member can end lower memberships and its own one
user = self.memberships[2].user
qs = Membership.objects.editable_by(user).values_list("id", flat=True)
expected = {self.memberships[2].id, self.memberships[3].id}
assert set(qs) == expected
class TestMembership(TestClub): class TestMembership(TestClub):
def assert_membership_started_today(self, user: User, role: int): def assert_membership_started_today(self, user: User, role: int):
"""Assert that the given membership is active and started today.""" """Assert that the given membership is active and started today."""
@@ -151,7 +190,7 @@ class TestMembership(TestClub):
def assert_membership_ended_today(self, user: User): def assert_membership_ended_today(self, user: User):
"""Assert that the given user have a membership which ended today.""" """Assert that the given user have a membership which ended today."""
today = localtime(now()).date() today = localdate()
assert user.memberships.filter(club=self.club, end_date=today).exists() assert user.memberships.filter(club=self.club, end_date=today).exists()
assert self.club.get_membership_for(user) is None assert self.club.get_membership_for(user) is None
@@ -160,7 +199,9 @@ class TestMembership(TestClub):
cannot see the page. cannot see the page.
""" """
response = self.client.post(self.members_url) response = self.client.post(self.members_url)
assert response.status_code == 403 assertRedirects(
response, reverse("core:login", query={"next": self.members_url})
)
self.client.force_login(self.public) self.client.force_login(self.public)
response = self.client.post(self.members_url) response = self.client.post(self.members_url)
@@ -171,7 +212,9 @@ class TestMembership(TestClub):
information are displayed. information are displayed.
""" """
self.client.force_login(self.simple_board_member) self.client.force_login(self.simple_board_member)
response = self.client.get(self.members_url) response = self.client.get(
reverse("club:club_members", kwargs={"club_id": self.club.id})
)
assert response.status_code == 200 assert response.status_code == 200
soup = BeautifulSoup(response.text, "lxml") soup = BeautifulSoup(response.text, "lxml")
table = soup.find("table", id="club_members_table") table = soup.find("table", id="club_members_table")
@@ -197,59 +240,45 @@ class TestMembership(TestClub):
assert cols[2].text == membership.description assert cols[2].text == membership.description
assert cols[3].text == str(membership.start_date) assert cols[3].text == str(membership.start_date)
if membership.role <= 3: # 3 is the role of simple_board_member if membership.role < 3 or membership.user_id == self.simple_board_member.id:
# 3 is the role of simple_board_member
form_input = cols[4].find("input") form_input = cols[4].find("input")
expected_attrs = { expected_attrs = {
"type": "checkbox", "type": "checkbox",
"name": "users_old", "name": "members_old",
"value": str(user.id), "value": str(membership.id),
} }
assert form_input.attrs.items() >= expected_attrs.items() assert form_input.attrs.items() >= expected_attrs.items()
else: else:
assert cols[4].find_all() == [] assert cols[4].find_all() == []
def test_root_add_one_club_member(self): def test_root_add_one_club_member(self):
"""Test that root users can add members to clubs, one at a time.""" """Test that root users can add members to clubs"""
self.client.force_login(self.root) self.client.force_login(self.root)
response = self.client.post( response = self.client.post(
self.members_url, self.new_members_url, {"user": self.subscriber.id, "role": 3}
{"users": [self.subscriber.id], "role": 3}, )
assert response.status_code == 200
assert response.headers.get("HX-Redirect", "") == reverse(
"club:club_members", kwargs={"club_id": self.club.id}
) )
self.assertRedirects(response, self.members_url)
self.subscriber.refresh_from_db() self.subscriber.refresh_from_db()
self.assert_membership_started_today(self.subscriber, role=3) self.assert_membership_started_today(self.subscriber, role=3)
def test_root_add_multiple_club_member(self):
"""Test that root users can add multiple members at once to clubs."""
self.client.force_login(self.root)
response = self.client.post(
self.members_url,
{
"users": (self.subscriber.id, self.krophil.id),
"role": 3,
},
)
self.assertRedirects(response, self.members_url)
self.subscriber.refresh_from_db()
self.assert_membership_started_today(self.subscriber, role=3)
self.assert_membership_started_today(self.krophil, role=3)
def test_add_unauthorized_members(self): def test_add_unauthorized_members(self):
"""Test that users who are not currently subscribed """Test that users who are not currently subscribed
cannot be members of clubs. cannot be members of clubs.
""" """
for user in self.public, self.old_subscriber: for user in self.public, self.old_subscriber:
form = ClubMemberForm( form = ClubAddMemberForm(
data={"users": [user.id], "role": 1}, data={"user": user.id, "role": 1},
request_user=self.root, request_user=self.root,
club=self.club, club=self.club,
) )
assert not form.is_valid() assert not form.is_valid()
assert form.errors == { assert form.errors == {
"users": [ "user": ["L'utilisateur doit être cotisant pour faire partie d'un club"]
"L'utilisateur doit être cotisant pour faire partie d'un club"
]
} }
def test_add_members_already_members(self): def test_add_members_already_members(self):
@@ -281,16 +310,16 @@ class TestMembership(TestClub):
nb_memberships = self.club.members.count() nb_memberships = self.club.members.count()
max_id = User.objects.aggregate(id=Max("id"))["id"] max_id = User.objects.aggregate(id=Max("id"))["id"]
for members in [max_id + 1], [max_id + 1, self.subscriber.id]: for members in [max_id + 1], [max_id + 1, self.subscriber.id]:
form = ClubMemberForm( form = ClubAddMemberForm(
data={"users": members, "role": 1}, data={"user": members, "role": 1},
request_user=self.root, request_user=self.root,
club=self.club, club=self.club,
) )
assert not form.is_valid() assert not form.is_valid()
assert form.errors == { assert form.errors == {
"users": [ "user": [
"Sélectionnez un choix valide. " "Sélectionnez un choix valide. "
f"{max_id + 1} n\u2019en fait pas partie." "Ce choix ne fait pas partie de ceux disponibles."
] ]
} }
self.club.refresh_from_db() self.club.refresh_from_db()
@@ -303,10 +332,12 @@ class TestMembership(TestClub):
nb_subscriber_memberships = self.subscriber.memberships.count() nb_subscriber_memberships = self.subscriber.memberships.count()
self.client.force_login(president) self.client.force_login(president)
response = self.client.post( response = self.client.post(
self.members_url, self.new_members_url, {"user": self.subscriber.id, "role": 9}
{"users": self.subscriber.id, "role": 9}, )
assert response.status_code == 200
assert response.headers.get("HX-Redirect", "") == reverse(
"club:club_members", kwargs={"club_id": self.club.id}
) )
self.assertRedirects(response, self.members_url)
self.club.refresh_from_db() self.club.refresh_from_db()
self.subscriber.refresh_from_db() self.subscriber.refresh_from_db()
assert self.club.members.count() == nb_club_membership + 1 assert self.club.members.count() == nb_club_membership + 1
@@ -317,8 +348,8 @@ class TestMembership(TestClub):
"""Test that a member of the club member cannot create """Test that a member of the club member cannot create
a membership with a greater role than its own. a membership with a greater role than its own.
""" """
form = ClubMemberForm( form = ClubAddMemberForm(
data={"users": [self.subscriber.id], "role": 10}, data={"user": self.subscriber.id, "role": 10},
request_user=self.simple_board_member, request_user=self.simple_board_member,
club=self.club, club=self.club,
) )
@@ -326,7 +357,7 @@ class TestMembership(TestClub):
assert not form.is_valid() assert not form.is_valid()
assert form.errors == { assert form.errors == {
"__all__": ["Vous n'avez pas la permission de faire cela"] "role": ["Sélectionnez un choix valide. 10 n\u2019en fait pas partie."]
} }
self.club.refresh_from_db() self.club.refresh_from_db()
assert nb_memberships == self.club.members.count() assert nb_memberships == self.club.members.count()
@@ -334,23 +365,53 @@ class TestMembership(TestClub):
def test_add_member_without_role(self): def test_add_member_without_role(self):
"""Test that trying to add members without specifying their role fails.""" """Test that trying to add members without specifying their role fails."""
self.client.force_login(self.root) form = ClubAddMemberForm(
form = ClubMemberForm( data={"user": self.subscriber.id}, request_user=self.root, club=self.club
data={"users": [self.subscriber.id]},
request_user=self.simple_board_member,
club=self.club,
) )
assert not form.is_valid() assert not form.is_valid()
assert form.errors == {"role": ["Vous devez choisir un rôle"]} assert form.errors == {"role": ["Ce champ est obligatoire."]}
def test_add_member_already_there(self):
form = ClubAddMemberForm(
data={"user": self.simple_board_member, "role": 3},
request_user=self.root,
club=self.club,
)
assert not form.is_valid()
assert form.errors == {
"user": ["Vous ne pouvez pas ajouter deux fois le même utilisateur"]
}
def test_add_other_member_forbidden(self):
non_member = subscriber_user.make()
simple_member = baker.make(Membership, club=self.club, role=1).user
for user in non_member, simple_member:
form = ClubAddMemberForm(
data={"user": subscriber_user.make(), "role": 1},
request_user=user,
club=self.club,
)
assert not form.is_valid()
assert form.errors == {
"role": ["Sélectionnez un choix valide. 1 n\u2019en fait pas partie."]
}
def test_simple_members_dont_see_form_anymore(self):
"""Test that simple club members don't see the form to add members"""
user = subscriber_user.make()
baker.make(Membership, club=self.club, user=user, role=1)
self.client.force_login(user)
res = self.client.get(self.members_url)
assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml")
assert not soup.find(id="add_club_members_form")
def test_end_membership_self(self): def test_end_membership_self(self):
"""Test that a member can end its own membership.""" """Test that a member can end its own membership."""
self.client.force_login(self.simple_board_member) self.client.force_login(self.simple_board_member)
self.client.post( membership = self.club.members.get(end_date=None, user=self.simple_board_member)
self.members_url, self.client.post(self.members_url, {"members_old": [membership.id]})
{"users_old": self.simple_board_member.id},
)
self.simple_board_member.refresh_from_db() self.simple_board_member.refresh_from_db()
self.assert_membership_ended_today(self.simple_board_member) self.assert_membership_ended_today(self.simple_board_member)
@@ -358,15 +419,13 @@ class TestMembership(TestClub):
"""Test that board members of the club can end memberships """Test that board members of the club can end memberships
of users with lower roles. of users with lower roles.
""" """
# remainder : simple_board_member has role 3, president has role 10, richard has role 1 # reminder : simple_board_member has role 3
self.client.force_login(self.simple_board_member) self.client.force_login(self.simple_board_member)
response = self.client.post( membership = baker.make(Membership, club=self.club, role=2, end_date=None)
self.members_url, response = self.client.post(self.members_url, {"members_old": [membership.id]})
{"users_old": self.richard.id},
)
self.assertRedirects(response, self.members_url) self.assertRedirects(response, self.members_url)
self.club.refresh_from_db() self.club.refresh_from_db()
self.assert_membership_ended_today(self.richard) self.assert_membership_ended_today(membership.user)
def test_end_membership_higher_role(self): def test_end_membership_higher_role(self):
"""Test that board members of the club cannot end memberships """Test that board members of the club cannot end memberships
@@ -374,46 +433,30 @@ class TestMembership(TestClub):
""" """
membership = self.president.memberships.filter(club=self.club).first() membership = self.president.memberships.filter(club=self.club).first()
self.client.force_login(self.simple_board_member) self.client.force_login(self.simple_board_member)
self.client.post( self.client.post(self.members_url, {"members_old": [membership.id]})
self.members_url,
{"users_old": self.president.id},
)
self.club.refresh_from_db() self.club.refresh_from_db()
new_membership = self.club.get_membership_for(self.president) new_membership = self.club.get_membership_for(self.president)
assert new_membership is not None assert new_membership is not None
assert new_membership == membership assert new_membership == membership
membership = self.president.memberships.filter(club=self.club).first() membership.refresh_from_db()
assert membership.end_date is None assert membership.end_date is None
def test_end_membership_as_main_club_board(self): def test_end_membership_with_permission(self):
"""Test that board members of the main club can end the membership """Test that users with permission can end any membership."""
of anyone.
"""
# make subscriber a board member # make subscriber a board member
subscriber = subscriber_user.make()
Membership.objects.create(club=self.ae, user=subscriber, role=3)
nb_memberships = self.club.members.ongoing().count() nb_memberships = self.club.members.ongoing().count()
self.client.force_login(subscriber) self.client.force_login(
subscriber_user.make(
user_permissions=[Permission.objects.get(codename="change_membership")]
)
)
president_membership = self.club.president
response = self.client.post( response = self.client.post(
self.members_url, self.members_url, {"members_old": [president_membership.id]}
{"users_old": self.president.id},
) )
self.assertRedirects(response, self.members_url) self.assertRedirects(response, self.members_url)
self.assert_membership_ended_today(self.president) self.assert_membership_ended_today(president_membership.user)
assert self.club.members.ongoing().count() == nb_memberships - 1
def test_end_membership_as_root(self):
"""Test that root users can end the membership of anyone."""
nb_memberships = self.club.members.ongoing().count()
self.client.force_login(self.root)
response = self.client.post(
self.members_url,
{"users_old": [self.president.id]},
)
self.assertRedirects(response, self.members_url)
self.assert_membership_ended_today(self.president)
assert self.club.members.ongoing().count() == nb_memberships - 1 assert self.club.members.ongoing().count() == nb_memberships - 1
def test_end_membership_as_foreigner(self): def test_end_membership_as_foreigner(self):
@@ -421,14 +464,11 @@ class TestMembership(TestClub):
nb_memberships = self.club.members.count() nb_memberships = self.club.members.count()
membership = self.richard.memberships.filter(club=self.club).first() membership = self.richard.memberships.filter(club=self.club).first()
self.client.force_login(self.subscriber) self.client.force_login(self.subscriber)
self.client.post( self.client.post(self.members_url, {"members_old": [self.richard.id]})
self.members_url,
{"users_old": [self.richard.id]},
)
# nothing should have changed # nothing should have changed
new_mem = self.club.get_membership_for(self.richard) membership.refresh_from_db()
assert self.club.members.count() == nb_memberships assert self.club.members.count() == nb_memberships
assert membership == new_mem assert membership.end_date is None
def test_remove_from_club_group(self): def test_remove_from_club_group(self):
"""Test that when a membership ends, the user is removed from club groups.""" """Test that when a membership ends, the user is removed from club groups."""
@@ -490,3 +530,85 @@ class TestMembership(TestClub):
new_board = set(self.club.board_group.users.values_list("id", flat=True)) new_board = set(self.club.board_group.users.values_list("id", flat=True))
assert new_members == initial_members assert new_members == initial_members
assert new_board == initial_board assert new_board == initial_board
@pytest.mark.django_db
class TestJoinClub:
@pytest.fixture(autouse=True)
def clear_cache(self):
cache.clear()
@pytest.mark.parametrize(
("user_factory", "role", "errors"),
[
(
subscriber_user.make,
2,
{
"role": [
"Sélectionnez un choix valide. 2 n\u2019en fait pas partie."
]
},
),
(
lambda: baker.make(User),
1,
{"__all__": ["Vous devez être cotisant pour faire partie d'un club"]},
),
],
)
def test_join_club_errors(
self, user_factory: Callable[[], User], role: int, errors: dict
):
club = baker.make(Club)
user = user_factory()
form = JoinClubForm(club=club, request_user=user, data={"role": role})
assert not form.is_valid()
assert form.errors == errors
def test_user_already_in_club(self):
club = baker.make(Club)
user = subscriber_user.make()
baker.make(Membership, user=user, club=club)
form = JoinClubForm(club=club, request_user=user, data={"role": 1})
assert not form.is_valid()
assert form.errors == {"__all__": ["Vous êtes déjà membre de ce club."]}
def test_ok(self):
club = baker.make(Club)
user = subscriber_user.make()
form = JoinClubForm(club=club, request_user=user, data={"role": 1})
assert form.is_valid()
form.save()
assert Membership.objects.ongoing().filter(user=user, club=club).exists()
class TestOldMembersView(TestCase):
@classmethod
def setUpTestData(cls):
club = baker.make(Club)
roles = [1, 1, 1, 2, 2, 4, 4, 5, 7, 9, 10]
cls.memberships = baker.make(
Membership,
role=iter(roles),
club=club,
start_date=now() - timedelta(days=14),
end_date=now() - timedelta(days=7),
_quantity=len(roles),
_bulk_create=True,
)
cls.url = reverse("club:club_old_members", kwargs={"club_id": club.id})
def test_ok(self):
user = subscriber_user.make()
self.client.force_login(user)
res = self.client.get(self.url)
assert res.status_code == 200
def test_access_forbidden(self):
res = self.client.get(self.url)
assertRedirects(res, reverse("core:login", query={"next": self.url}))
self.client.force_login(baker.make(User))
res = self.client.get(self.url)
assert res.status_code == 403

View File

@@ -0,0 +1,35 @@
import pytest
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from club.models import Club
from com.models import Poster
from core.baker_recipes import subscriber_user
@pytest.mark.django_db
@pytest.mark.parametrize("route_url", ["club:poster_list", "club:poster_create"])
def test_access(client: Client, route_url):
club = baker.make(Club)
user = subscriber_user.make()
url = reverse(route_url, kwargs={"club_id": club.id})
client.force_login(user)
assert client.get(url).status_code == 403
club.board_group.users.add(user)
assert client.get(url).status_code == 200
@pytest.mark.django_db
@pytest.mark.parametrize("route_url", ["club:poster_edit", "club:poster_delete"])
def test_access_specific_poster(client: Client, route_url):
club = baker.make(Club)
user = subscriber_user.make()
poster = baker.make(Poster)
url = reverse(route_url, kwargs={"club_id": club.id, "poster_id": poster.id})
client.force_login(user)
assert client.get(url).status_code == 403
club.board_group.users.add(user)
assert client.get(url).status_code == 200

View File

@@ -25,6 +25,7 @@
from django.urls import path from django.urls import path
from club.views import ( from club.views import (
ClubAddMembersFragment,
ClubCreateView, ClubCreateView,
ClubEditView, ClubEditView,
ClubListView, ClubListView,
@@ -60,6 +61,11 @@ urlpatterns = [
path("<int:club_id>/edit/", ClubEditView.as_view(), name="club_edit"), path("<int:club_id>/edit/", ClubEditView.as_view(), name="club_edit"),
path("<int:club_id>/edit/page/", ClubPageEditView.as_view(), name="club_edit_page"), path("<int:club_id>/edit/page/", ClubPageEditView.as_view(), name="club_edit_page"),
path("<int:club_id>/members/", ClubMembersView.as_view(), name="club_members"), path("<int:club_id>/members/", ClubMembersView.as_view(), name="club_members"),
path(
"fragment/<int:club_id>/members/",
ClubAddMembersFragment.as_view(),
name="club_new_members",
),
path( path(
"<int:club_id>/elderlies/", "<int:club_id>/elderlies/",
ClubOldMembersView.as_view(), ClubOldMembersView.as_view(),

View File

@@ -23,52 +23,57 @@
# #
import csv import csv
from typing import Any
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
from django.core.paginator import InvalidPage, Paginator from django.core.paginator import InvalidPage, Paginator
from django.db.models import Sum from django.db.models import Q, Sum
from django.http import ( from django.http import Http404, HttpResponseRedirect, StreamingHttpResponse
Http404,
HttpResponseRedirect,
StreamingHttpResponse,
)
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.safestring import SafeString
from django.utils.timezone import now
from django.utils.translation import gettext as _t from django.utils.translation import gettext as _t
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, View from django.views.generic import DetailView, ListView, View
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.forms import ( from club.forms import (
ClubAddMemberForm,
ClubAdminEditForm, ClubAdminEditForm,
ClubEditForm, ClubEditForm,
ClubMemberForm, ClubOldMemberForm,
JoinClubForm,
MailingForm, MailingForm,
SellingsForm, SellingsForm,
) )
from club.models import Club, Mailing, MailingSubscription, Membership from club.models import Club, Mailing, MailingSubscription, Membership
from com.models import Poster
from com.views import ( from com.views import (
PosterCreateBaseView, PosterCreateBaseView,
PosterDeleteBaseView, PosterDeleteBaseView,
PosterEditBaseView, PosterEditBaseView,
PosterListBaseView, PosterListBaseView,
) )
from core.auth.mixins import CanCreateMixin, CanEditMixin, CanViewMixin from core.auth.mixins import CanEditMixin
from core.models import PageRev from core.models import PageRev
from core.views import DetailFormView, PageEditViewBase from core.views import DetailFormView, PageEditViewBase, UseFragmentsMixin
from core.views.mixins import TabedViewMixin from core.views.mixins import FragmentMixin, FragmentRenderer, TabedViewMixin
from counter.models import Selling from counter.models import Selling
class ClubTabsMixin(TabedViewMixin): class ClubTabsMixin(TabedViewMixin):
def get_tabs_title(self): def get_tabs_title(self):
obj = self.get_object() if not hasattr(self, "object") or not self.object:
if isinstance(obj, PageRev): self.object = self.get_object()
self.object = obj.page.club if isinstance(self.object, PageRev):
self.object = self.object.page.club
elif isinstance(self.object, Poster):
self.object = self.object.club
return self.object.get_display_name() return self.object.get_display_name()
def get_list_of_tabs(self): def get_list_of_tabs(self):
@@ -79,7 +84,7 @@ class ClubTabsMixin(TabedViewMixin):
"name": _("Infos"), "name": _("Infos"),
} }
] ]
if self.request.user.can_view(self.object): if self.request.user.has_perm("club.view_club"):
tab_list.extend( tab_list.extend(
[ [
{ {
@@ -159,7 +164,7 @@ class ClubTabsMixin(TabedViewMixin):
"club:poster_list", kwargs={"club_id": self.object.id} "club:poster_list", kwargs={"club_id": self.object.id}
), ),
"slug": "posters", "slug": "posters",
"name": _("Posters list"), "name": _("Posters"),
}, },
] ]
) )
@@ -171,6 +176,10 @@ class ClubListView(ListView):
model = Club model = Club
template_name = "club/club_list.jinja" template_name = "club/club_list.jinja"
queryset = (
Club.objects.filter(parent=None).order_by("name").prefetch_related("children")
)
context_object_name = "club_list"
class ClubView(ClubTabsMixin, DetailView): class ClubView(ClubTabsMixin, DetailView):
@@ -224,13 +233,14 @@ class ClubPageEditView(ClubTabsMixin, PageEditViewBase):
return reverse_lazy("club:club_view", kwargs={"club_id": self.club.id}) return reverse_lazy("club:club_view", kwargs={"club_id": self.club.id})
class ClubPageHistView(ClubTabsMixin, CanViewMixin, DetailView): class ClubPageHistView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
"""Modification hostory of the page.""" """Modification hostory of the page."""
model = Club model = Club
pk_url_kwarg = "club_id" pk_url_kwarg = "club_id"
template_name = "club/page_history.jinja" template_name = "club/page_history.jinja"
current_tab = "history" current_tab = "history"
permission_required = "club.view_club"
class ClubToolsView(ClubTabsMixin, CanEditMixin, DetailView): class ClubToolsView(ClubTabsMixin, CanEditMixin, DetailView):
@@ -242,57 +252,121 @@ class ClubToolsView(ClubTabsMixin, CanEditMixin, DetailView):
current_tab = "tools" current_tab = "tools"
class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView): class ClubAddMembersFragment(
FragmentMixin, PermissionRequiredMixin, SuccessMessageMixin, CreateView
):
template_name = "club/fragments/add_member.jinja"
model = Membership
object = None
reload_on_redirect = True
permission_required = "club.view_club"
def dispatch(self, *args, **kwargs):
self.club = get_object_or_404(Club, pk=kwargs.get("club_id"))
return super().dispatch(*args, **kwargs)
def get_form_class(self):
user = self.request.user
if user.has_perm("club.add_membership") or self.club.get_membership_for(user):
return ClubAddMemberForm
return JoinClubForm
def get_form_kwargs(self):
return super().get_form_kwargs() | {
"request_user": self.request.user,
"club": self.club,
}
def render_fragment(self, request, **kwargs) -> SafeString:
self.club = kwargs.get("club")
return super().render_fragment(request, **kwargs)
def get_success_url(self):
return reverse("club:club_members", kwargs={"club_id": self.club.id})
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"club": self.club}
def get_success_message(self, cleaned_data):
if "user" not in cleaned_data or cleaned_data["user"] == self.request.user:
return _("You are now a member of this club.")
return _("%(user)s has been added to club.") % cleaned_data
class ClubMembersView(
ClubTabsMixin, UseFragmentsMixin, PermissionRequiredMixin, DetailFormView
):
"""View of a club's members.""" """View of a club's members."""
model = Club model = Club
pk_url_kwarg = "club_id" pk_url_kwarg = "club_id"
form_class = ClubMemberForm form_class = ClubOldMemberForm
template_name = "club/club_members.jinja" template_name = "club/club_members.jinja"
current_tab = "members" current_tab = "members"
permission_required = "club.view_club"
@cached_property def get_fragments(self) -> dict[str, type[FragmentMixin] | FragmentRenderer]:
def members(self) -> list[Membership]: membership = self.object.get_membership_for(self.request.user)
return list(self.object.members.ongoing().order_by("-role")) if (
membership
and membership.role <= settings.SITH_MAXIMUM_FREE_ROLE
and not self.request.user.has_perm("club.add_membership")
):
# Simple club members won't see the form anymore.
# Even if they saw it, they couldn't add anyone to the club anyway
return {}
return {"add_member_fragment": ClubAddMembersFragment}
def get_fragment_data(self) -> dict[str, Any]:
return {"add_member_fragment": {"club": self.object}}
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() return super().get_form_kwargs() | {
kwargs["request_user"] = self.request.user "user": self.request.user,
kwargs["club"] = self.object "club": self.object,
kwargs["club_members"] = self.members }
return kwargs
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["members"] = self.members editable = list(
kwargs["form"].fields["members_old"].queryset.values_list("id", flat=True)
)
kwargs["members"] = list(
self.object.members.ongoing()
.annotate(is_editable=Q(id__in=editable))
.order_by("-role")
.select_related("user")
)
kwargs["can_end_membership"] = len(editable) > 0
return kwargs return kwargs
def form_valid(self, form): def form_valid(self, form):
"""Check user rights.""" for membership in form.cleaned_data.get("members_old"):
resp = super().form_valid(form) membership.end_date = now()
data = form.clean()
users = data.pop("users", [])
users_old = data.pop("users_old", [])
for user in users:
Membership(club=self.object, user=user, **data).save()
for user in users_old:
membership = self.object.get_membership_for(user)
membership.end_date = timezone.now()
membership.save() membership.save()
return resp return super().form_valid(form)
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return self.request.path return self.request.path
class ClubOldMembersView(ClubTabsMixin, CanViewMixin, DetailView): class ClubOldMembersView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
"""Old members of a club.""" """Old members of a club."""
model = Club model = Club
pk_url_kwarg = "club_id" pk_url_kwarg = "club_id"
template_name = "club/club_old_members.jinja" template_name = "club/club_old_members.jinja"
current_tab = "elderlies" current_tab = "elderlies"
permission_required = "club.view_club"
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"old_members": (
self.object.members.exclude(end_date=None)
.order_by("-role", "description", "-end_date")
.select_related("user")
)
}
class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView): class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
@@ -333,7 +407,7 @@ class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
form = self.get_form() form = self.get_form()
if form.is_valid(): if form.is_valid():
if not len([v for v in form.cleaned_data.values() if v is not None]): if not len([v for v in form.cleaned_data.values() if v is not None]):
qs = Selling.objects.filter(id=-1) qs = Selling.objects.none()
if form.cleaned_data["begin_date"]: if form.cleaned_data["begin_date"]:
qs = qs.filter(date__gte=form.cleaned_data["begin_date"]) qs = qs.filter(date__gte=form.cleaned_data["begin_date"])
if form.cleaned_data["end_date"]: if form.cleaned_data["end_date"]:
@@ -351,7 +425,9 @@ class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
if len(selected_products) > 0: if len(selected_products) > 0:
qs = qs.filter(product__in=selected_products) qs = qs.filter(product__in=selected_products)
kwargs["result"] = qs.all().order_by("-id") kwargs["result"] = qs.select_related(
"counter", "counter__club", "customer", "customer__user", "seller"
).order_by("-id")
kwargs["total"] = sum([s.quantity * s.unit_price for s in kwargs["result"]]) kwargs["total"] = sum([s.quantity * s.unit_price for s in kwargs["result"]])
total_quantity = qs.all().aggregate(Sum("quantity")) total_quantity = qs.all().aggregate(Sum("quantity"))
if total_quantity["quantity__sum"]: if total_quantity["quantity__sum"]:
@@ -682,48 +758,45 @@ class MailingAutoGenerationView(View):
return redirect("club:mailing", club_id=club.id) return redirect("club:mailing", club_id=club.id)
class PosterListView(ClubTabsMixin, PosterListBaseView, CanViewMixin): class PosterListView(ClubTabsMixin, PosterListBaseView):
"""List communication posters.""" """List communication posters."""
current_tab = "posters"
extra_context = {"app": "club"}
def get_queryset(self):
return super().get_queryset().filter(club=self.club.id)
def get_object(self): def get_object(self):
return self.club return self.club
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["app"] = "club"
kwargs["club"] = self.club
return kwargs
class PosterCreateView(ClubTabsMixin, PosterCreateBaseView):
class PosterCreateView(PosterCreateBaseView, CanCreateMixin):
"""Create communication poster.""" """Create communication poster."""
pk_url_kwarg = "club_id" current_tab = "posters"
def get_object(self):
obj = super().get_object()
if not obj:
return self.club
return obj
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id}) return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id})
def get_object(self, *args, **kwargs):
return self.club
class PosterEditView(ClubTabsMixin, PosterEditBaseView, CanEditMixin):
class PosterEditView(ClubTabsMixin, PosterEditBaseView):
"""Edit communication poster.""" """Edit communication poster."""
current_tab = "posters"
extra_context = {"app": "club"}
def get_success_url(self): def get_success_url(self):
return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id}) return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id})
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["app"] = "club"
return kwargs
class PosterDeleteView(ClubTabsMixin, PosterDeleteBaseView):
class PosterDeleteView(PosterDeleteBaseView, ClubTabsMixin, CanEditMixin):
"""Delete communication poster.""" """Delete communication poster."""
current_tab = "posters"
def get_success_url(self): def get_success_url(self):
return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id}) return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id})

View File

@@ -2,7 +2,6 @@ from datetime import date
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django import forms from django import forms
from django.db.models import Exists, OuterRef
from django.forms import CheckboxInput from django.forms import CheckboxInput
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -35,20 +34,18 @@ class PosterForm(forms.ModelForm):
label=_("Start date"), label=_("Start date"),
widget=SelectDateTime, widget=SelectDateTime,
required=True, required=True,
initial=timezone.now().strftime("%Y-%m-%d %H:%M:%S"), initial=timezone.now(),
) )
date_end = forms.DateTimeField( date_end = forms.DateTimeField(
label=_("End date"), widget=SelectDateTime, required=False label=_("End date"), widget=SelectDateTime, required=False
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, user: User, **kwargs):
self.user = kwargs.pop("user", None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if self.user and not self.user.is_com_admin: if user.is_root or user.is_com_admin:
self.fields["club"].queryset = Club.objects.filter( self.fields["club"].widget = AutoCompleteSelectClub()
id__in=self.user.clubs_with_rights else:
) self.fields["club"].queryset = Club.objects.having_board_member(user)
self.fields.pop("display_time")
class NewsDateForm(forms.ModelForm): class NewsDateForm(forms.ModelForm):
@@ -161,16 +158,9 @@ class NewsForm(forms.ModelForm):
# if the author is an admin, he/she can choose any club, # if the author is an admin, he/she can choose any club,
# otherwise, only clubs for which he/she is a board member can be selected # otherwise, only clubs for which he/she is a board member can be selected
if author.is_root or author.is_com_admin: if author.is_root or author.is_com_admin:
self.fields["club"] = forms.ModelChoiceField( self.fields["club"].widget = AutoCompleteSelectClub()
queryset=Club.objects.all(), widget=AutoCompleteSelectClub
)
else: else:
active_memberships = author.memberships.board().ongoing() self.fields["club"].queryset = Club.objects.having_board_member(author)
self.fields["club"] = forms.ModelChoiceField(
queryset=Club.objects.filter(
Exists(active_memberships.filter(club=OuterRef("pk")))
)
)
def is_valid(self): def is_valid(self):
return super().is_valid() and self.date_form.is_valid() return super().is_valid() and self.date_form.is_valid()

View File

@@ -68,7 +68,7 @@ class IcsCalendar:
start=news_date.start_date, start=news_date.start_date,
end=news_date.end_date, end=news_date.end_date,
url=as_absolute_url( url=as_absolute_url(
reverse("com:news_detail", kwargs={"news_id": news_date.news.id}) reverse("com:news_detail", kwargs={"news_id": news_date.news_id})
), ),
) )
calendar.events.append(event) calendar.events.append(event)

View File

@@ -54,7 +54,7 @@ class Migration(migrations.Migration):
migrations.AddConstraint( migrations.AddConstraint(
model_name="newsdate", model_name="newsdate",
constraint=models.CheckConstraint( constraint=models.CheckConstraint(
check=models.Q(("end_date__gte", models.F("start_date"))), condition=models.Q(("end_date__gte", models.F("start_date"))),
name="news_date_end_date_after_start_date", name="news_date_end_date_after_start_date",
), ),
), ),

View File

@@ -27,7 +27,7 @@ from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.mail import EmailMultiAlternatives from django.core.mail import EmailMultiAlternatives
from django.db import models, transaction from django.db import models, transaction
from django.db.models import F, Q from django.db.models import Exists, F, OuterRef, Q
from django.shortcuts import render from django.shortcuts import render
from django.templatetags.static import static from django.templatetags.static import static
from django.urls import reverse from django.urls import reverse
@@ -55,9 +55,17 @@ class Sith(models.Model):
class NewsQuerySet(models.QuerySet): class NewsQuerySet(models.QuerySet):
def moderated(self) -> Self: def published(self) -> Self:
return self.filter(is_published=True) return self.filter(is_published=True)
def waiting_moderation(self) -> Self:
"""Filter all non-finished non-published news"""
# Because of the way News and NewsDates are created,
# there may be some cases where this method is called before
# the NewsDates linked to a Date are actually persisted in db.
# Thus, it's important to filter by "not past date" rather than by "future date"
return self.filter(~Q(dates__start_date__lt=timezone.now()), is_published=False)
def viewable_by(self, user: User) -> Self: def viewable_by(self, user: User) -> Self:
"""Filter news that the given user can view. """Filter news that the given user can view.
@@ -127,20 +135,28 @@ class News(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
super().save(*args, **kwargs) super().save(*args, **kwargs)
if self.is_published: if not self.is_published:
return admins_without_notif = User.objects.filter(
for user in User.objects.filter( ~Exists(
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID] Notification.objects.filter(
): user=OuterRef("pk"), type="NEWS_MODERATION"
Notification.objects.create(
user=user, url=reverse("com:news_admin_list"), type="NEWS_MODERATION"
) )
),
groups__id=settings.SITH_GROUP_COM_ADMIN_ID,
)
notif_url = reverse("com:news_admin_list")
new_notifs = [
Notification(user=user, url=notif_url, type="NEWS_MODERATION")
for user in admins_without_notif
]
Notification.objects.bulk_create(new_notifs)
self.update_moderation_notifs()
def get_absolute_url(self): def get_absolute_url(self):
return reverse("com:news_detail", kwargs={"news_id": self.id}) return reverse("com:news_detail", kwargs={"news_id": self.id})
def get_full_url(self): def get_full_url(self):
return "https://%s%s" % (settings.SITH_URL, self.get_absolute_url()) return f"https://{settings.SITH_URL}{self.get_absolute_url()}"
def is_owned_by(self, user): def is_owned_by(self, user):
if user.is_anonymous: if user.is_anonymous:
@@ -159,19 +175,16 @@ class News(models.Model):
or (user.is_authenticated and self.author_id == user.id) or (user.is_authenticated and self.author_id == user.id)
) )
@staticmethod
def news_notification_callback(notif: Notification): def update_moderation_notifs():
# the NewsDate linked to the News count = News.objects.waiting_moderation().count()
# which creation triggered this callback may not exist yet, notifs_qs = Notification.objects.filter(
# so it's important to filter by "not past date" rather than by "future date" type="NEWS_MODERATION", user__groups__id=settings.SITH_GROUP_COM_ADMIN_ID
count = News.objects.filter( )
~Q(dates__start_date__gt=timezone.now()), is_published=False
).count()
if count: if count:
notif.viewed = False notifs_qs.update(viewed=False, param=str(count))
notif.param = str(count)
else: else:
notif.viewed = True notifs_qs.update(viewed=True)
class NewsDateQuerySet(models.QuerySet): class NewsDateQuerySet(models.QuerySet):
@@ -212,7 +225,7 @@ class NewsDate(models.Model):
verbose_name_plural = _("news dates") verbose_name_plural = _("news dates")
constraints = [ constraints = [
models.CheckConstraint( models.CheckConstraint(
check=Q(end_date__gte=F("start_date")), condition=Q(end_date__gte=F("start_date")),
name="news_date_end_date_after_start_date", name="news_date_end_date_after_start_date",
) )
] ]
@@ -399,17 +412,5 @@ class Poster(models.Model):
if self.date_end and self.date_begin > self.date_end: if self.date_end and self.date_begin > self.date_end:
raise ValidationError(_("Begin date should be before end date")) raise ValidationError(_("Begin date should be before end date"))
def is_owned_by(self, user):
if user.is_anonymous:
return False
return user.is_com_admin or len(user.clubs_with_rights) > 0
def can_be_moderated_by(self, user):
return user.is_com_admin
def get_display_name(self): def get_display_name(self):
return self.club.get_display_name() return self.club.get_display_name()
@property
def page(self):
return self.club.page

View File

@@ -0,0 +1,49 @@
const INTERVAL = 10;
interface Poster {
url: string; // URL of the poster
displayTime: number; // Number of seconds to display that poster
}
document.addEventListener("alpine:init", () => {
Alpine.data("slideshow", (posters: Poster[]) => ({
posters: posters,
progress: 0,
elapsed: 0,
current: 0,
previous: 0,
init() {
this.$watch("elapsed", () => {
const displayTime = this.posters[this.current].displayTime * 1000;
if (this.elapsed > displayTime) {
this.previous = this.current;
this.current = this.getNext();
this.elapsed = 0;
}
if (displayTime === 0) {
this.progress = 100;
} else {
this.progress = (100 * this.elapsed) / displayTime;
}
});
setInterval(() => {
this.elapsed += INTERVAL;
}, INTERVAL);
},
getNext() {
return (this.current + 1) % this.posters.length;
},
async toggleFullScreen(event: Event) {
if (document.fullscreenElement) {
await document.exitFullscreen();
return;
}
const target = event.target as HTMLElement;
await target.requestFullscreen();
},
}));
});

View File

@@ -83,7 +83,8 @@
#links_content { #links_content {
overflow: auto; overflow: auto;
box-shadow: $shadow-color 1px 1px 1px; box-shadow: $shadow-color 1px 1px 1px;
height: 20em; min-height: 20em;
padding-bottom: 1em;
h4 { h4 {
margin-left: 5px; margin-left: 5px;

View File

@@ -111,7 +111,7 @@
top: 0; top: 0;
left: 0; left: 0;
z-index: 10; z-index: 10;
content: "Click to expand"; content: attr(hover);
color: white; color: white;
background-color: rgba(black, 0.5); background-color: rgba(black, 0.5);
} }

View File

@@ -1,23 +0,0 @@
$(document).ready(() => {
$("#poster_list #view").click(() => {
$("#view").removeClass("active");
});
$("#poster_list .poster .image").click((e) => {
let el = $(e.target);
if (el.hasClass("image")) {
el = el.find("img");
}
$("#poster_list #view #placeholder").html(el.clone());
$("#view").addClass("active");
});
$(document).keyup((e) => {
if (e.keyCode === 27) {
// escape key maps to keycode `27`
e.preventDefault();
$("#view").removeClass("active");
}
});
});

View File

@@ -1,98 +0,0 @@
$(document).ready(() => {
const transitionTime = 1000;
let i = 0;
const max = $("#slideshow .slide").length;
function enterFullscreen() {
const element = document.getElementById("slideshow");
$(element).addClass("fullscreen");
if (element.requestFullscreen) {
element.requestFullscreen();
} else if (element.mozRequestFullScreen) {
element.mozRequestFullScreen();
} else if (element.webkitRequestFullscreen) {
element.webkitRequestFullscreen();
} else if (element.msRequestFullscreen) {
element.msRequestFullscreen();
}
}
function exitFullscreen() {
const element = document.getElementById("slideshow");
$(element).removeClass("fullscreen");
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen();
} else if (document.msExitFullscreen) {
document.msExitFullscreen();
}
}
function initProgressBar() {
$("#slideshow #progress_bar").css("transition", "none");
$("#slideshow #progress_bar").removeClass("progress");
$("#slideshow #progress_bar").addClass("init");
}
function startProgressBar(displayTime) {
$("#slideshow #progress_bar").removeClass("init");
$("#slideshow #progress_bar").addClass("progress");
$("#slideshow #progress_bar").css("transition", `width ${displayTime}s linear`);
}
function next() {
initProgressBar();
const slide = $($("#slideshow .slide").get(i % max));
slide.removeClass("center");
slide.addClass("left");
const nextSlide = $($("#slideshow .slide").get((i + 1) % max));
nextSlide.removeClass("right");
nextSlide.addClass("center");
const displayTime = nextSlide.attr("display_time") || 2;
$("#slideshow .bullet").removeClass("active");
const bullet = $("#slideshow .bullet")[(i + 1) % max];
$(bullet).addClass("active");
i = (i + 1) % max;
setTimeout(() => {
const othersLeft = $("#slideshow .slide.left");
othersLeft.removeClass("left");
othersLeft.addClass("right");
startProgressBar(displayTime);
setTimeout(next, displayTime * 1000);
}, transitionTime);
}
const displayTime = $("#slideshow .center").attr("display_time");
initProgressBar();
setTimeout(() => {
if (max > 1) {
startProgressBar(displayTime);
setTimeout(next, displayTime * 1000);
}
}, 10);
$("#slideshow").click(() => {
if ($("#slideshow").hasClass("fullscreen")) {
exitFullscreen();
} else {
enterFullscreen();
}
});
$(document).keyup((e) => {
if (e.keyCode === 27) {
// escape key maps to keycode `27`
e.preventDefault();
exitFullscreen();
}
});
});

View File

@@ -1,4 +1,4 @@
body{ body {
position: absolute; position: absolute;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
@@ -7,22 +7,22 @@ body{
margin: 0; margin: 0;
} }
#slideshow{ #slideshow {
position: relative; position: relative;
background-color: lightgrey; background-color: lightgrey;
height: 100%; height: 100%;
*{ * {
-webkit-user-select: none; -webkit-user-select: none;
-moz-user-select: none; -moz-user-select: none;
-ms-user-select: none; -ms-user-select: none;
user-select: none; user-select: none;
} }
&:hover{ &:hover {
&::before{ &::before {
position: absolute; position: absolute;
width: 100%; width: 100%;
@@ -34,7 +34,7 @@ body{
z-index: 10; z-index: 10;
content: "Click to expand"; content: attr(hover);
color: white; color: white;
background-color: rgba(black, 0.5); background-color: rgba(black, 0.5);
@@ -43,7 +43,7 @@ body{
} }
&.fullscreen{ &:fullscreen {
position: fixed; position: fixed;
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -51,57 +51,78 @@ body{
left: 0; left: 0;
background: none; background: none;
&:before{ &:before {
display:none; display: none;
} }
#slides{ #slides {
height: 100vh; height: 100vh;
} }
} }
#slides{ #slides {
position: relative; position: relative;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
background-color: grey;
.slide{ .slide {
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 100%; height: 100%;
display: inline-flex; display: none;
justify-content: center; justify-content: center;
top: 0px; top: 0px;
left: 0%;
background-color: grey; img {
transition: left 1s ease-out;
img{
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
object-fit: contain; object-fit: contain;
} }
&.current {
display: inline-flex;
left: 0%;
animation: scrolling-in 1s linear;
} }
.slide.left{ &.previous {
left: -100%; display: inline-flex;
animation: scrolling-out 1s linear;
opacity: 0;
transition: opacity 0.1s;
transition-delay: 0.9s;
} }
.slide.center{ @keyframes scrolling-in {
left: 0px; 0% {
transform: translateX(100%);
}
100% {
transform: translateX(0%);
}
}
@keyframes scrolling-out {
0% {
transform: translateX(0%);
}
100% {
transform: translateX(-100%);
}
} }
.slide.right{
left: 100%;
transition: none;
} }
} }
#progress_bullets{ #progress_bullets {
position: absolute; position: absolute;
bottom: 10px; bottom: 10px;
width: 100%; width: 100%;
@@ -112,7 +133,7 @@ body{
margin-bottom: 10px; margin-bottom: 10px;
.bullet{ .bullet {
height: 10px; height: 10px;
width: 10px; width: 10px;
@@ -123,27 +144,33 @@ body{
background-color: grey; background-color: grey;
&.active{ &.active {
background-color: #c99836; background-color: #c99836;
} }
} }
} }
#progress_bar{ progress {
--color: #304c83;
position: absolute; position: absolute;
bottom: 0px; bottom: 0px;
height: 10px; height: 10px;
background-color: #304c83; color: var(--color);
width: 100%;
margin-bottom: 0px;
border: none;
&.init{ &::-moz-progress-bar {
width: 0px; background: var(--color);
transition: none;
} }
&.progress{ &::-webkit-progress-value {
width: 100%; background: var(--color);
transition: width 10s linear; }
&[value] {
background-color: transparent;
} }
} }
} }

View File

@@ -76,18 +76,20 @@
It will stay hidden for other users until it has been published. It will stay hidden for other users until it has been published.
{% endtrans %} {% endtrans %}
</p> </p>
{% if user.has_perm("com.moderate_news") %} {%- if user.has_perm("com.moderate_news") -%}
{# This is an additional query for each non-moderated news, {# This is an additional query for each non-moderated news,
but it will be executed only for admin users, and only one time but it will be executed only for admin users, and only one time
(if they do their job and moderated news as soon as they see them), (if they do their job and moderate news as soon as they see them),
so it's still reasonable #} so it's still reasonable #}
<div <div
{% if news is integer or news is string %} {% if news is integer or news is string -%}
x-data="{ nbEvents: 0 }" x-data="{ nbEvents: 0 }"
x-init="nbEvents = await nbToPublish()" x-init="nbEvents = await nbToPublish()"
{% else %} {%- elif news.is_published -%}
x-data="{ nbEvents: 0 }"
{%- else -%}
x-data="{ nbEvents: {{ news.dates.count() }} }" x-data="{ nbEvents: {{ news.dates.count() }} }"
{% endif %} {%- endif -%}
> >
<template x-if="nbEvents > 1"> <template x-if="nbEvents > 1">
<div> <div>

View File

@@ -1,10 +1,6 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from "com/macros.jinja" import news_moderation_alert %} {% from "com/macros.jinja" import news_moderation_alert %}
{% block title %}
{% trans %}News{% endtrans %}
{% endblock %}
{% block additional_css %} {% block additional_css %}
<link rel="stylesheet" href="{{ static('com/css/news-list.scss') }}"> <link rel="stylesheet" href="{{ static('com/css/news-list.scss') }}">
<link rel="stylesheet" href="{{ static('com/components/ics-calendar.scss') }}"> <link rel="stylesheet" href="{{ static('com/components/ics-calendar.scss') }}">
@@ -209,6 +205,10 @@
<i class="fa-solid fa-graduation-cap fa-xl"></i> <i class="fa-solid fa-graduation-cap fa-xl"></i>
<a href="{{ url("pedagogy:guide") }}">{% trans %}UV Guide{% endtrans %}</a> <a href="{{ url("pedagogy:guide") }}">{% trans %}UV Guide{% endtrans %}</a>
</li> </li>
<li>
<i class="fa-solid fa-calendar-days fa-xl"></i>
<a href="{{ url("timetable:generator") }}">{% trans %}Timetable{% endtrans %}</a>
</li>
<li> <li>
<i class="fa-solid fa-magnifying-glass fa-xl"></i> <i class="fa-solid fa-magnifying-glass fa-xl"></i>
<a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a> <a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>

View File

@@ -1,11 +1,5 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% block script %}
{{ super() }}
<script src="{{ static('com/js/poster_list.js') }}"></script>
{% endblock %}
{% block title %} {% block title %}
{% trans %}Poster{% endtrans %} {% trans %}Poster{% endtrans %}
{% endblock %} {% endblock %}
@@ -15,7 +9,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div id="poster_list"> <div id="poster_list" x-data="{ active: null }">
<div id="title"> <div id="title">
<h3>{% trans %}Posters{% endtrans %}</h3> <h3>{% trans %}Posters{% endtrans %}</h3>
@@ -38,7 +32,13 @@
{% for poster in poster_list %} {% for poster in poster_list %}
<div class="poster{% if not poster.is_moderated %} not_moderated{% endif %}"> <div class="poster{% if not poster.is_moderated %} not_moderated{% endif %}">
<div class="name">{{ poster.name }}</div> <div class="name">{{ poster.name }}</div>
<div class="image"><img src="{{ poster.file.url }}"></img></div> <div
class="image"
hover="{% trans %}Click to expand{% endtrans %}"
@click="active = $el.firstElementChild"
>
<img src="{{ poster.file.url }}"></img>
</div>
<div class="dates"> <div class="dates">
<div class="begin">{{ poster.date_begin | localtime | date("d/M/Y H:m") }}</div> <div class="begin">{{ poster.date_begin | localtime | date("d/M/Y H:m") }}</div>
<div class="end">{{ poster.date_end | localtime | date("d/M/Y H:m") }}</div> <div class="end">{{ poster.date_end | localtime | date("d/M/Y H:m") }}</div>
@@ -62,7 +62,14 @@
</div> </div>
<div id="view"><div id="placeholder"></div></div> <div
id="view"
@keyup.escape.window="active = null"
@click="active = null"
:class="{active: active !== null}"
>
<div id="placeholder"><img :src="active?.src"></div>
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -2,28 +2,44 @@
<html lang="fr"> <html lang="fr">
<head> <head>
<title>{% trans %}Slideshow{% endtrans %}</title> <title>{% trans %}Slideshow{% endtrans %}</title>
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
<link href="{{ static('css/slideshow.scss') }}" rel="stylesheet" type="text/css" /> <link href="{{ static('css/slideshow.scss') }}" rel="stylesheet" type="text/css" />
<script src="{{ static('bundled/vendored/jquery.min.js') }}"></script> <script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
<script src="{{ static('com/js/slideshow.js') }}"></script> <script type="module" src="{{ static('bundled/com/slideshow-index.ts') }}"></script>
</head> </head>
<body> <body x-data="slideshow([
<div id="slideshow"> {% for poster in posters %}
{
url: '{{ poster.file.url }}',
displayTime: {{ poster.display_time }}
},
{% endfor %}
])">
<div
id="slideshow"
@click="toggleFullScreen"
hover="{% trans %}Click to expand{% endtrans %}"
@keyup.f.window="toggleFullScreen"
>
<div id="slides"> <div id="slides">
{% for poster in posters %} <template x-for="(poster, index) in posters">
<div class="slide {% if loop.first %}center{% else %}right{% endif %}" display_time="{{ poster.display_time }}"> <div class="slide" :class="{
<img src="{{ poster.file.url }}"> current: index === current,
previous: index !== current && index === previous,
}">
<img :src="poster.url">
</div> </div>
{% endfor %} </template>
</div> </div>
<div id="progress_bullets"> <div id="progress_bullets">
{% for poster in posters %} <template x-for="(poster, index) in posters">
<div class="bullet {% if loop.first %}active{% endif %}"></div> <div class="bullet" :class="{active: current === index}"></div>
{% endfor %} </template>
</div> </div>
<div id="progress_bar"></div> <progress :value="progress" max="100" x-show="posters.length > 1 && progress > 0"></progress>
</div> </div>
</body> </body>

View File

@@ -31,9 +31,7 @@
<td> <td>
<a href="{{ url('com:weekmail_article_edit', article_id=a.id) }}">{% trans %}Edit{% endtrans %}</a> | <a href="{{ url('com:weekmail_article_edit', article_id=a.id) }}">{% trans %}Edit{% endtrans %}</a> |
<a href="{{ url('com:weekmail_article_delete', article_id=a.id) }}">{% trans %}Delete{% endtrans %}</a> | <a href="{{ url('com:weekmail_article_delete', article_id=a.id) }}">{% trans %}Delete{% endtrans %}</a> |
<a href="?add_article={{ a.id }}">{% trans %}Add to weekmail{% endtrans %}</a> | <a href="?add_article={{ a.id }}">{% trans %}Add to weekmail{% endtrans %}</a>
<a href="?up_article={{ a.id }}">{% trans %}Up{% endtrans %}</a> |
<a href="?down_article={{ a.id }}">{% trans %}Down{% endtrans %}</a>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@@ -1,13 +1,22 @@
from datetime import timedelta
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.utils.timezone import now
from model_bakery import baker from model_bakery import baker
from com.models import News from com.models import News, NewsDate
from core.baker_recipes import subscriber_user
from core.models import Group, Notification, User from core.models import Group, Notification, User
@pytest.mark.django_db @pytest.mark.django_db
def test_notification_created(): def test_notification_created():
# this news is unpublished, but is set in the past
# it shouldn't be taken into account when counting the number
# of news that are to be moderated
past_news = baker.make(News, is_published=False)
baker.make(NewsDate, news=past_news, start_date=now() - timedelta(days=1))
com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID) com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)
com_admin_group.users.all().delete() com_admin_group.users.all().delete()
Notification.objects.all().delete() Notification.objects.all().delete()
@@ -15,9 +24,28 @@ def test_notification_created():
for i in range(2): for i in range(2):
# news notifications are permanent, so the notification created # news notifications are permanent, so the notification created
# during the first iteration should be reused during the second one. # during the first iteration should be reused during the second one.
baker.make(News) baker.make(News, is_published=False)
notifications = list(Notification.objects.all()) notifications = list(Notification.objects.all())
assert len(notifications) == 1 assert len(notifications) == 1
assert notifications[0].user == com_admin assert notifications[0].user == com_admin
assert notifications[0].type == "NEWS_MODERATION" assert notifications[0].type == "NEWS_MODERATION"
assert notifications[0].param == str(i + 1) assert notifications[0].param == str(i + 1)
@pytest.mark.django_db
def test_notification_edited_when_moderating_news():
com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)
com_admins = subscriber_user.make(_quantity=3)
com_admin_group.users.set(com_admins)
Notification.objects.all().delete()
news = baker.make(News, is_published=False)
assert Notification.objects.count() == 3
assert Notification.objects.filter(viewed=False).count() == 3
news.is_published = True
news.moderator = com_admins[0]
news.save()
# when the news is moderated, the notification should be marked as read
# for all admins
assert Notification.objects.count() == 3
assert Notification.objects.filter(viewed=False).count() == 0

View File

@@ -18,17 +18,16 @@ from unittest.mock import patch
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import html from django.utils import html
from django.utils.timezone import localtime, now from django.utils.timezone import now
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertNumQueries, assertRedirects from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club, Membership from club.models import Club, Membership
from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle from com.models import News, NewsDate, Sith, Weekmail, WeekmailArticle
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, Group, User from core.models import AnonymousUser, Group, User
@@ -207,31 +206,6 @@ class TestWeekmailArticle(TestCase):
assert not self.article.is_owned_by(self.sli) assert not self.article.is_owned_by(self.sli)
class TestPoster(TestCase):
@classmethod
def setUpTestData(cls):
cls.com_admin = User.objects.get(username="comunity")
cls.poster = Poster.objects.create(
name="dummy",
file=SimpleUploadedFile("dummy.jpg", b"azertyuiop"),
club=Club.objects.first(),
date_begin=localtime(now()),
)
cls.sli = User.objects.get(username="sli")
cls.sli.memberships.all().delete()
Membership(user=cls.sli, club=Club.objects.first(), role=5).save()
cls.susbcriber = User.objects.get(username="subscriber")
cls.anonymous = AnonymousUser()
def test_poster_owner(self):
"""Test that poster are owned by com admins and board members in clubs."""
assert self.poster.is_owned_by(self.com_admin)
assert not self.poster.is_owned_by(self.anonymous)
assert not self.poster.is_owned_by(self.susbcriber)
assert self.poster.is_owned_by(self.sli)
class TestNewsCreation(TestCase): class TestNewsCreation(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@@ -28,7 +28,10 @@ from typing import Any
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin from django.contrib import messages
from django.contrib.auth.mixins import (
PermissionRequiredMixin,
)
from django.contrib.syndication.views import Feed from django.contrib.syndication.views import Feed
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
from django.db.models import Max from django.db.models import Max
@@ -50,9 +53,10 @@ from core.auth.mixins import (
CanEditPropMixin, CanEditPropMixin,
CanViewMixin, CanViewMixin,
PermissionOrAuthorRequiredMixin, PermissionOrAuthorRequiredMixin,
PermissionOrClubBoardRequiredMixin,
) )
from core.models import User from core.models import User
from core.views.mixins import QuickNotifMixin, TabedViewMixin from core.views.mixins import TabedViewMixin
from core.views.widgets.markdown import MarkdownInput from core.views.widgets.markdown import MarkdownInput
# Sith object # Sith object
@@ -99,13 +103,6 @@ class ComTabsMixin(TabedViewMixin):
] ]
class IsComAdminMixin(AccessMixin):
def dispatch(self, request, *args, **kwargs):
if not request.user.is_com_admin:
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
class ComEditView(ComTabsMixin, CanEditPropMixin, UpdateView): class ComEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
model = Sith model = Sith
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
@@ -337,7 +334,7 @@ class NewsFeed(Feed):
# Weekmail # Weekmail
class WeekmailPreviewView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, DetailView): class WeekmailPreviewView(ComTabsMixin, CanEditPropMixin, DetailView):
model = Weekmail model = Weekmail
template_name = "com/weekmail_preview.jinja" template_name = "com/weekmail_preview.jinja"
success_url = reverse_lazy("com:weekmail") success_url = reverse_lazy("com:weekmail")
@@ -349,12 +346,11 @@ class WeekmailPreviewView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, Detai
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
messages.success(self.request, _("Weekmail sent successfully"))
if request.POST["send"] == "validate": if request.POST["send"] == "validate":
try: try:
self.object.send() self.object.send()
return HttpResponseRedirect( return HttpResponseRedirect(reverse("com:weekmail"))
reverse("com:weekmail") + "?qn_weekmail_send_success"
)
except SMTPRecipientsRefused as e: except SMTPRecipientsRefused as e:
self.bad_recipients = e.recipients self.bad_recipients = e.recipients
elif request.POST["send"] == "clean": elif request.POST["send"] == "clean":
@@ -365,7 +361,6 @@ class WeekmailPreviewView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, Detai
for u in users: for u in users:
u.preferences.receive_weekmail = False u.preferences.receive_weekmail = False
u.preferences.save() u.preferences.save()
self.quick_notif_list += ["qn_success"]
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def get_object(self, queryset=None): def get_object(self, queryset=None):
@@ -379,7 +374,7 @@ class WeekmailPreviewView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, Detai
return kwargs return kwargs
class WeekmailEditView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateView): class WeekmailEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
model = Weekmail model = Weekmail
template_name = "com/weekmail.jinja" template_name = "com/weekmail.jinja"
form_class = modelform_factory( form_class = modelform_factory(
@@ -419,7 +414,10 @@ class WeekmailEditView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateVi
art.rank, prev_art.rank = prev_art.rank, art.rank art.rank, prev_art.rank = prev_art.rank, art.rank
art.save() art.save()
prev_art.save() prev_art.save()
self.quick_notif_list += ["qn_success"] messages.success(
self.request,
_("%(title)s moved up in the Weekmail") % {"title": art.title},
)
if "down_article" in request.GET: if "down_article" in request.GET:
art = get_object_or_404( art = get_object_or_404(
WeekmailArticle, id=request.GET["down_article"], weekmail=self.object WeekmailArticle, id=request.GET["down_article"], weekmail=self.object
@@ -431,7 +429,10 @@ class WeekmailEditView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateVi
art.rank, next_art.rank = next_art.rank, art.rank art.rank, next_art.rank = next_art.rank, art.rank
art.save() art.save()
next_art.save() next_art.save()
self.quick_notif_list += ["qn_success"] messages.success(
self.request,
_("%(title)s moved down in the Weekmail") % {"title": art.title},
)
if "add_article" in request.GET: if "add_article" in request.GET:
art = get_object_or_404( art = get_object_or_404(
WeekmailArticle, id=request.GET["add_article"], weekmail=None WeekmailArticle, id=request.GET["add_article"], weekmail=None
@@ -440,7 +441,10 @@ class WeekmailEditView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateVi
art.rank = self.object.articles.aggregate(Max("rank"))["rank__max"] or 0 art.rank = self.object.articles.aggregate(Max("rank"))["rank__max"] or 0
art.rank += 1 art.rank += 1
art.save() art.save()
self.quick_notif_list += ["qn_success"] messages.success(
self.request,
_("%(title)s added to the Weekmail") % {"title": art.title},
)
if "del_article" in request.GET: if "del_article" in request.GET:
art = get_object_or_404( art = get_object_or_404(
WeekmailArticle, id=request.GET["del_article"], weekmail=self.object WeekmailArticle, id=request.GET["del_article"], weekmail=self.object
@@ -448,7 +452,10 @@ class WeekmailEditView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateVi
art.weekmail = None art.weekmail = None
art.rank = -1 art.rank = -1
art.save() art.save()
self.quick_notif_list += ["qn_success"] messages.success(
self.request,
_("%(title)s removed from the Weekmail") % {"title": art.title},
)
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@@ -458,9 +465,7 @@ class WeekmailEditView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateVi
return kwargs return kwargs
class WeekmailArticleEditView( class WeekmailArticleEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateView
):
"""Edit an article.""" """Edit an article."""
model = WeekmailArticle model = WeekmailArticle
@@ -472,11 +477,10 @@ class WeekmailArticleEditView(
pk_url_kwarg = "article_id" pk_url_kwarg = "article_id"
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
success_url = reverse_lazy("com:weekmail") success_url = reverse_lazy("com:weekmail")
quick_notif_url_arg = "qn_weekmail_article_edit"
current_tab = "weekmail" current_tab = "weekmail"
class WeekmailArticleCreateView(QuickNotifMixin, CreateView): class WeekmailArticleCreateView(CreateView):
"""Post an article.""" """Post an article."""
model = WeekmailArticle model = WeekmailArticle
@@ -487,7 +491,6 @@ class WeekmailArticleCreateView(QuickNotifMixin, CreateView):
) )
template_name = "core/create.jinja" template_name = "core/create.jinja"
success_url = reverse_lazy("core:user_tools") success_url = reverse_lazy("core:user_tools")
quick_notif_url_arg = "qn_weekmail_new_article"
def get_initial(self): def get_initial(self):
if "club" not in self.request.GET: if "club" not in self.request.GET:
@@ -558,161 +561,109 @@ class MailingModerateView(View):
raise PermissionDenied raise PermissionDenied
class PosterAdminViewMixin(IsComAdminMixin, ComTabsMixin): class PosterListBaseView(PermissionOrClubBoardRequiredMixin, ListView):
current_tab = "posters"
class PosterListBaseView(PosterAdminViewMixin, ListView):
"""List communication posters.""" """List communication posters."""
current_tab = "posters"
model = Poster model = Poster
template_name = "com/poster_list.jinja" template_name = "com/poster_list.jinja"
permission_required = "com.view_poster"
def dispatch(self, request, *args, **kwargs): ordering = ["-date_begin"]
club_id = kwargs.pop("club_id", None)
self.club = None
if club_id:
self.club = get_object_or_404(Club, pk=club_id)
return super().dispatch(request, *args, **kwargs)
def get_queryset(self):
if self.request.user.is_com_admin:
return Poster.objects.all().order_by("-date_begin")
else:
return Poster.objects.filter(club=self.club.id)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) return super().get_context_data(**kwargs) | {"club": self.club}
if not self.request.user.is_com_admin:
kwargs["club"] = self.club
return kwargs
class PosterCreateBaseView(PosterAdminViewMixin, CreateView): class PosterCreateBaseView(PermissionOrClubBoardRequiredMixin, CreateView):
"""Create communication poster.""" """Create communication poster."""
current_tab = "posters"
form_class = PosterForm form_class = PosterForm
template_name = "core/create.jinja" template_name = "core/create.jinja"
permission_required = "com.add_poster"
def get_queryset(self): def get_queryset(self):
return Poster.objects.all() return Poster.objects.all()
def dispatch(self, request, *args, **kwargs):
if "club_id" in kwargs:
self.club = get_object_or_404(Club, pk=kwargs["club_id"])
return super().dispatch(request, *args, **kwargs)
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() return super().get_form_kwargs() | {"user": self.request.user}
kwargs.update({"user": self.request.user})
return kwargs def get_initial(self):
return {"club": self.club}
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) return super().get_context_data(**kwargs) | {"club": self.club}
if not self.request.user.is_com_admin:
kwargs["club"] = self.club
return kwargs
def form_valid(self, form): def form_valid(self, form):
if self.request.user.is_com_admin: if self.request.user.has_perm("com.moderate_poster"):
form.instance.is_moderated = True form.instance.is_moderated = True
return super().form_valid(form) return super().form_valid(form)
class PosterEditBaseView(PosterAdminViewMixin, UpdateView): class PosterEditBaseView(PermissionOrClubBoardRequiredMixin, UpdateView):
"""Edit communication poster.""" """Edit communication poster."""
pk_url_kwarg = "poster_id" pk_url_kwarg = "poster_id"
current_tab = "posters"
form_class = PosterForm form_class = PosterForm
template_name = "com/poster_edit.jinja" template_name = "com/poster_edit.jinja"
permission_required = "com.change_poster"
def get_initial(self):
return {
"date_begin": self.object.date_begin.strftime("%Y-%m-%d %H:%M:%S")
if self.object.date_begin
else None,
"date_end": self.object.date_end.strftime("%Y-%m-%d %H:%M:%S")
if self.object.date_end
else None,
}
def dispatch(self, request, *args, **kwargs):
if kwargs.get("club_id"):
try:
self.club = Club.objects.get(pk=kwargs["club_id"])
except Club.DoesNotExist as e:
raise PermissionDenied from e
return super().dispatch(request, *args, **kwargs)
def get_queryset(self): def get_queryset(self):
return Poster.objects.all() return Poster.objects.all()
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() return super().get_form_kwargs() | {"user": self.request.user}
kwargs.update({"user": self.request.user})
return kwargs
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) return super().get_context_data(**kwargs) | {"club": self.club}
if hasattr(self, "club"):
kwargs["club"] = self.club
return kwargs
def form_valid(self, form): def form_valid(self, form):
if self.request.user.is_com_admin: if not self.request.user.has_perm("com.moderate_poster"):
form.instance.is_moderated = False form.instance.is_moderated = False
return super().form_valid(form) return super().form_valid(form)
class PosterDeleteBaseView(PosterAdminViewMixin, DeleteView): class PosterDeleteBaseView(
PermissionOrClubBoardRequiredMixin, ComTabsMixin, DeleteView
):
"""Edit communication poster.""" """Edit communication poster."""
pk_url_kwarg = "poster_id" pk_url_kwarg = "poster_id"
current_tab = "posters" current_tab = "posters"
model = Poster model = Poster
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
permission_required = "com.delete_poster"
def dispatch(self, request, *args, **kwargs):
if kwargs.get("club_id"):
try:
self.club = Club.objects.get(pk=kwargs["club_id"])
except Club.DoesNotExist as e:
raise PermissionDenied from e
return super().dispatch(request, *args, **kwargs)
class PosterListView(PosterListBaseView): class PosterListView(ComTabsMixin, PosterListBaseView):
"""List communication posters.""" """List communication posters."""
current_tab = "posters"
def get_queryset(self):
qs = super().get_queryset()
if self.request.user.has_perm("com.view_poster"):
return qs
return qs.filter(club=self.club.id)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["app"] = "com" kwargs["app"] = "com"
return kwargs return kwargs
class PosterCreateView(PosterCreateBaseView): class PosterCreateView(ComTabsMixin, PosterCreateBaseView):
"""Create communication poster.""" """Create communication poster."""
current_tab = "posters"
success_url = reverse_lazy("com:poster_list") success_url = reverse_lazy("com:poster_list")
extra_context = {"app": "com"}
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["app"] = "com"
return kwargs
class PosterEditView(PosterEditBaseView): class PosterEditView(ComTabsMixin, PosterEditBaseView):
"""Edit communication poster.""" """Edit communication poster."""
current_tab = "posters"
success_url = reverse_lazy("com:poster_list") success_url = reverse_lazy("com:poster_list")
extra_context = {"app": "com"}
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["app"] = "com"
return kwargs
class PosterDeleteView(PosterDeleteBaseView): class PosterDeleteView(PosterDeleteBaseView):
@@ -721,44 +672,39 @@ class PosterDeleteView(PosterDeleteBaseView):
success_url = reverse_lazy("com:poster_list") success_url = reverse_lazy("com:poster_list")
class PosterModerateListView(PosterAdminViewMixin, ListView): class PosterModerateListView(PermissionRequiredMixin, ComTabsMixin, ListView):
"""Moderate list communication poster.""" """Moderate list communication poster."""
current_tab = "posters" current_tab = "posters"
model = Poster model = Poster
template_name = "com/poster_moderate.jinja" template_name = "com/poster_moderate.jinja"
queryset = Poster.objects.filter(is_moderated=False).all() queryset = Poster.objects.filter(is_moderated=False).all()
permission_required = "com.moderate_poster"
def get_context_data(self, **kwargs): extra_context = {"app": "com"}
kwargs = super().get_context_data(**kwargs)
kwargs["app"] = "com"
return kwargs
class PosterModerateView(PosterAdminViewMixin, View): class PosterModerateView(PermissionRequiredMixin, ComTabsMixin, View):
"""Moderate communication poster.""" """Moderate communication poster."""
current_tab = "posters"
permission_required = "com.moderate_poster"
extra_context = {"app": "com"}
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
obj = get_object_or_404(Poster, pk=kwargs["object_id"]) obj = get_object_or_404(Poster, pk=kwargs["object_id"])
if obj.can_be_moderated_by(request.user):
obj.is_moderated = True obj.is_moderated = True
obj.moderator = request.user obj.moderator = request.user
obj.save() obj.save()
return redirect("com:poster_moderate_list") return redirect("com:poster_moderate_list")
raise PermissionDenied
def get_context_data(self, **kwargs):
kwargs = super(PosterModerateListView, self).get_context_data(**kwargs)
kwargs["app"] = "com"
return kwargs
class ScreenListView(IsComAdminMixin, ComTabsMixin, ListView): class ScreenListView(PermissionRequiredMixin, ComTabsMixin, ListView):
"""List communication screens.""" """List communication screens."""
current_tab = "screens" current_tab = "screens"
model = Screen model = Screen
template_name = "com/screen_list.jinja" template_name = "com/screen_list.jinja"
permission_required = "com.view_screen"
class ScreenSlideshowView(DetailView): class ScreenSlideshowView(DetailView):
@@ -769,12 +715,12 @@ class ScreenSlideshowView(DetailView):
template_name = "com/screen_slideshow.jinja" template_name = "com/screen_slideshow.jinja"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) return super().get_context_data(**kwargs) | {
kwargs["posters"] = self.object.active_posters() "posters": self.object.active_posters()
return kwargs }
class ScreenCreateView(IsComAdminMixin, ComTabsMixin, CreateView): class ScreenCreateView(PermissionRequiredMixin, ComTabsMixin, CreateView):
"""Create communication screen.""" """Create communication screen."""
current_tab = "screens" current_tab = "screens"
@@ -782,9 +728,10 @@ class ScreenCreateView(IsComAdminMixin, ComTabsMixin, CreateView):
fields = ["name"] fields = ["name"]
template_name = "core/create.jinja" template_name = "core/create.jinja"
success_url = reverse_lazy("com:screen_list") success_url = reverse_lazy("com:screen_list")
permission_required = "com.add_screen"
class ScreenEditView(IsComAdminMixin, ComTabsMixin, UpdateView): class ScreenEditView(PermissionRequiredMixin, ComTabsMixin, UpdateView):
"""Edit communication screen.""" """Edit communication screen."""
pk_url_kwarg = "screen_id" pk_url_kwarg = "screen_id"
@@ -793,9 +740,10 @@ class ScreenEditView(IsComAdminMixin, ComTabsMixin, UpdateView):
fields = ["name"] fields = ["name"]
template_name = "com/screen_edit.jinja" template_name = "com/screen_edit.jinja"
success_url = reverse_lazy("com:screen_list") success_url = reverse_lazy("com:screen_list")
permission_required = "com.change_screen"
class ScreenDeleteView(IsComAdminMixin, ComTabsMixin, DeleteView): class ScreenDeleteView(PermissionRequiredMixin, ComTabsMixin, DeleteView):
"""Delete communication screen.""" """Delete communication screen."""
pk_url_kwarg = "screen_id" pk_url_kwarg = "screen_id"
@@ -803,3 +751,4 @@ class ScreenDeleteView(IsComAdminMixin, ComTabsMixin, DeleteView):
model = Screen model = Screen
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("com:screen_list") success_url = reverse_lazy("com:screen_list")
permission_required = "com.delete_screen"

View File

@@ -88,9 +88,9 @@ class PageAdmin(admin.ModelAdmin):
@admin.register(SithFile) @admin.register(SithFile)
class SithFileAdmin(admin.ModelAdmin): class SithFileAdmin(admin.ModelAdmin):
list_display = ("name", "owner", "size", "date") list_display = ("name", "owner", "size", "date", "is_in_sas")
autocomplete_fields = ("parent", "owner", "moderator") autocomplete_fields = ("parent", "owner", "moderator")
search_fields = ("name",) search_fields = ("name", "parent__name")
@admin.register(OperationLog) @admin.register(OperationLog)

View File

@@ -25,6 +25,7 @@ from core.schemas import (
UserFamilySchema, UserFamilySchema,
UserFilterSchema, UserFilterSchema,
UserProfileSchema, UserProfileSchema,
UserSchema,
) )
from core.templatetags.renderer import markdown from core.templatetags.renderer import markdown
@@ -69,16 +70,22 @@ class MailingListController(ControllerBase):
return data return data
@api_controller("/user", permissions=[CanAccessLookup]) @api_controller("/user")
class UserController(ControllerBase): class UserController(ControllerBase):
@route.get("", response=list[UserProfileSchema]) @route.get("", response=list[UserProfileSchema], permissions=[CanAccessLookup])
def fetch_profiles(self, pks: Query[set[int]]): def fetch_profiles(self, pks: Query[set[int]]):
return User.objects.filter(pk__in=pks) return User.objects.filter(pk__in=pks)
@route.get("/{int:user_id}", response=UserSchema, permissions=[CanView])
def fetch_user(self, user_id: int):
"""Fetch a single user"""
return self.get_object_or_exception(User, id=user_id)
@route.get( @route.get(
"/search", "/search",
response=PaginatedResponseSchema[UserProfileSchema], response=PaginatedResponseSchema[UserProfileSchema],
url_name="search_users", url_name="search_users",
permissions=[CanAccessLookup],
) )
@paginate(PageNumberPaginationExtra, page_size=20) @paginate(PageNumberPaginationExtra, page_size=20)
def search_users(self, filters: Query[UserFilterSchema]): def search_users(self, filters: Query[UserFilterSchema]):
@@ -97,7 +104,7 @@ class SithFileController(ControllerBase):
) )
@paginate(PageNumberPaginationExtra, page_size=50) @paginate(PageNumberPaginationExtra, page_size=50)
def search_files(self, search: Annotated[str, annotated_types.MinLen(1)]): def search_files(self, search: Annotated[str, annotated_types.MinLen(1)]):
return SithFile.objects.filter(name__icontains=search) return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search)
@api_controller("/group") @api_controller("/group")

View File

@@ -29,8 +29,14 @@ from typing import TYPE_CHECKING, Any, LiteralString
from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin
from django.core.exceptions import ImproperlyConfigured, PermissionDenied from django.core.exceptions import ImproperlyConfigured, PermissionDenied
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property
from django.utils.translation import gettext as _
from django.views.generic.base import View from django.views.generic.base import View
from club.models import Club
if TYPE_CHECKING: if TYPE_CHECKING:
from django.db.models import Model from django.db.models import Model
@@ -297,3 +303,50 @@ class PermissionOrAuthorRequiredMixin(PermissionRequiredMixin):
self.author_field += "_id" self.author_field += "_id"
author_id = getattr(obj, self.author_field, None) author_id = getattr(obj, self.author_field, None)
return author_id == self.request.user.id return author_id == self.request.user.id
class PermissionOrClubBoardRequiredMixin(PermissionRequiredMixin):
"""Require that the user has the required perm or is the board of the club.
This mixin can be used in any view that is called from a url
having a `club_id` kwarg.
Example:
In `urls.py` :
```python
urlpatterns = [
path("foo/<int:club_id>/bar/", FooView.as_view())
]
```
In `views.py` :
```python
# this view is available to users that either have the
# "foo.view_foo" permission or are in the board of the club
# which id was given in the url
class FooView(PermissionOrClubBoardRequiredMixin, View):
permission_required = "foo.view_foo"
```
"""
club_pk_url_kwarg = "club_id"
@cached_property
def club(self):
club_id: str | int = self.kwargs.pop(self.club_pk_url_kwarg, None)
if club_id is None:
return None
if isinstance(club_id, int) or club_id.isdigit():
return get_object_or_404(Club, pk=club_id)
raise Http404(_("No club found with id %(id)s") % {"id": club_id})
def has_permission(self):
if self.request.user.is_anonymous:
return False
if super().has_permission():
return True
return self.club is not None and any(
g.id == self.club.board_group_id for g in self.request.user.cached_groups
)

View File

@@ -0,0 +1,41 @@
import pathlib
from django.apps import apps
from django.core.management.base import BaseCommand
from PIL import Image, UnidentifiedImageError
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument("number", type=int)
parser.add_argument("path", type=pathlib.Path)
parser.add_argument("-f", "--force", action="store_true")
def handle(self, number: int, path: pathlib.Path, force: int, *args, **options):
if not path.exists() or path.is_dir():
self.stderr.write(f"{path} is not a file or does not exist")
return
dest_path = (
pathlib.Path(apps.get_app_config("core").path)
/ "static"
/ "core"
/ "img"
/ f"promo_{number}.png"
)
if dest_path.exists() and not force:
over = input("File already exists, do you want to overwrite it? (y/N):")
if over.lower() != "y":
self.stdout.write("exiting")
return
try:
im = Image.open(path)
im.resize((120, 120), resample=Image.Resampling.LANCZOS).save(
dest_path, format="PNG"
)
self.stdout.write(
f"Promo logo moved and resized successfully at {dest_path}"
)
except UnidentifiedImageError:
self.stderr.write("image cannot be opened and identified.")

View File

@@ -110,6 +110,7 @@ class Command(BaseCommand):
p.save(force_lock=True) p.save(force_lock=True)
club_root = SithFile.objects.create(name="clubs", owner=root) club_root = SithFile.objects.create(name="clubs", owner=root)
sas = SithFile.objects.create(name="SAS", owner=root)
main_club = Club.objects.create( main_club = Club.objects.create(
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort" id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
) )
@@ -692,21 +693,33 @@ class Command(BaseCommand):
# SAS # SAS
for f in self.SAS_FIXTURE_PATH.glob("*"): for f in self.SAS_FIXTURE_PATH.glob("*"):
if f.is_dir(): if f.is_dir():
album = Album.objects.create(name=f.name, is_moderated=True) album = Album(
parent=sas,
name=f.name,
owner=root,
is_folder=True,
is_in_sas=True,
is_moderated=True,
)
album.clean()
album.save()
for p in f.iterdir(): for p in f.iterdir():
file = resize_image(Image.open(p), 1000, "WEBP") file = resize_image(Image.open(p), 1000, "WEBP")
pict = Picture( pict = Picture(
parent=album, parent=album,
name=p.name, name=p.name,
original=file, file=file,
owner=root, owner=root,
is_folder=False,
is_in_sas=True,
is_moderated=True, is_moderated=True,
mime_type="image/webp",
size=file.size,
) )
pict.original.name = pict.name pict.file.name = p.name
pict.generate_thumbnails()
pict.full_clean() pict.full_clean()
pict.generate_thumbnails()
pict.save() pict.save()
album.generate_thumbnail()
img_skia = Picture.objects.get(name="skia.jpg") img_skia = Picture.objects.get(name="skia.jpg")
img_sli = Picture.objects.get(name="sli.jpg") img_sli = Picture.objects.get(name="sli.jpg")
@@ -755,7 +768,7 @@ class Command(BaseCommand):
s = Subscription( s = Subscription(
member=user, member=user,
subscription_type=subscription_type, subscription_type=subscription_type,
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0], payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1][0],
) )
s.subscription_start = s.compute_start(start) s.subscription_start = s.compute_start(start)
s.subscription_end = s.compute_end( s.subscription_end = s.compute_end(

View File

@@ -94,7 +94,11 @@ class Command(BaseCommand):
username=self.faker.user_name(), username=self.faker.user_name(),
first_name=self.faker.first_name(), first_name=self.faker.first_name(),
last_name=self.faker.last_name(), last_name=self.faker.last_name(),
date_of_birth=self.faker.date_of_birth(minimum_age=15, maximum_age=25), date_of_birth=(
None
if random.random() < 0.2
else self.faker.date_of_birth(minimum_age=15, maximum_age=25)
),
email=self.faker.email(), email=self.faker.email(),
phone=self.faker.phone_number(), phone=self.faker.phone_number(),
address=self.faker.address(), address=self.faker.address(),

View File

@@ -154,7 +154,7 @@ class Migration(migrations.Migration):
migrations.AddConstraint( migrations.AddConstraint(
model_name="userban", model_name="userban",
constraint=models.CheckConstraint( constraint=models.CheckConstraint(
check=models.Q(("expires_at__gte", models.F("created_at"))), condition=models.Q(("expires_at__gte", models.F("created_at"))),
name="user_ban_end_after_start", name="user_ban_end_after_start",
), ),
), ),

View File

@@ -1,27 +0,0 @@
# Generated by Django 4.2.17 on 2025-01-26 15:01
from typing import TYPE_CHECKING
from django.db import migrations
from django.db.migrations.state import StateApps
if TYPE_CHECKING:
import core.models
def remove_sas_sithfiles(apps: StateApps, schema_editor):
SithFile: type[core.models.SithFile] = apps.get_model("core", "SithFile")
SithFile.objects.filter(is_in_sas=True).delete()
class Migration(migrations.Migration):
dependencies = [
("core", "0047_alter_notification_date_alter_notification_type"),
("sas", "0007_alter_peoplepicturerelation_picture_and_more"),
]
operations = [
migrations.RunPython(
remove_sas_sithfiles, reverse_code=migrations.RunPython.noop, elidable=True
)
]

View File

@@ -1,9 +0,0 @@
# Generated by Django 4.2.17 on 2025-02-14 11:58
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("core", "0048_remove_sithfiles")]
operations = [migrations.RemoveField(model_name="sithfile", name="is_in_sas")]

View File

@@ -560,7 +560,7 @@ class User(AbstractUser):
"""Determine if the object is owned by the user.""" """Determine if the object is owned by the user."""
if hasattr(obj, "is_owned_by") and obj.is_owned_by(self): if hasattr(obj, "is_owned_by") and obj.is_owned_by(self):
return True return True
if hasattr(obj, "owner_group") and self.is_in_group(pk=obj.owner_group.id): if hasattr(obj, "owner_group") and self.is_in_group(pk=obj.owner_group_id):
return True return True
return self.is_root return self.is_root
@@ -569,8 +569,14 @@ class User(AbstractUser):
if hasattr(obj, "can_be_edited_by") and obj.can_be_edited_by(self): if hasattr(obj, "can_be_edited_by") and obj.can_be_edited_by(self):
return True return True
if hasattr(obj, "edit_groups"): if hasattr(obj, "edit_groups"):
for pk in obj.edit_groups.values_list("pk", flat=True): if (
if self.is_in_group(pk=pk): hasattr(obj, "_prefetched_objects_cache")
and "edit_groups" in obj._prefetched_objects_cache
):
pks = [g.id for g in obj.edit_groups.all()]
else:
pks = list(obj.edit_groups.values_list("id", flat=True))
if any(self.is_in_group(pk=pk) for pk in pks):
return True return True
if isinstance(obj, User) and obj == self: if isinstance(obj, User) and obj == self:
return True return True
@@ -581,8 +587,17 @@ class User(AbstractUser):
if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self): if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self):
return True return True
if hasattr(obj, "view_groups"): if hasattr(obj, "view_groups"):
for pk in obj.view_groups.values_list("pk", flat=True): # if "view_groups" has already been prefetched, use
if self.is_in_group(pk=pk): # the prefetch cache, else fetch only the ids, to make
# the query lighter.
if (
hasattr(obj, "_prefetched_objects_cache")
and "view_groups" in obj._prefetched_objects_cache
):
pks = [g.id for g in obj.view_groups.all()]
else:
pks = list(obj.view_groups.values_list("id", flat=True))
if any(self.is_in_group(pk=pk) for pk in pks):
return True return True
return self.can_edit(obj) return self.can_edit(obj)
@@ -636,9 +651,6 @@ class User(AbstractUser):
class AnonymousUser(AuthAnonymousUser): class AnonymousUser(AuthAnonymousUser):
def __init__(self):
super().__init__()
@property @property
def was_subscribed(self): def was_subscribed(self):
return False return False
@@ -647,10 +659,6 @@ class AnonymousUser(AuthAnonymousUser):
def is_subscribed(self): def is_subscribed(self):
return False return False
@property
def subscribed(self):
return False
@property @property
def is_root(self): def is_root(self):
return False return False
@@ -745,7 +753,7 @@ class UserBan(models.Model):
fields=["ban_group", "user"], name="unique_ban_type_per_user" fields=["ban_group", "user"], name="unique_ban_type_per_user"
), ),
models.CheckConstraint( models.CheckConstraint(
check=Q(expires_at__gte=F("created_at")), condition=Q(expires_at__gte=F("created_at")),
name="user_ban_end_after_start", name="user_ban_end_after_start",
), ),
] ]
@@ -863,6 +871,9 @@ class SithFile(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
asked_for_removal = models.BooleanField(_("asked for removal"), default=False) asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
is_in_sas = models.BooleanField(
_("is in the SAS"), default=False, db_index=True
) # Allows to query this flag, updated at each call to save()
class Meta: class Meta:
verbose_name = _("file") verbose_name = _("file")
@@ -871,10 +882,22 @@ class SithFile(models.Model):
return self.get_parent_path() + "/" + self.name return self.get_parent_path() + "/" + self.name
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
sas = SithFile.objects.filter(id=settings.SITH_SAS_ROOT_DIR_ID).first()
self.is_in_sas = sas in self.get_parent_list() or self == sas
adding = self._state.adding adding = self._state.adding
super().save(*args, **kwargs) super().save(*args, **kwargs)
if adding: if adding:
self.copy_rights() self.copy_rights()
if self.is_in_sas:
for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
):
Notification(
user=user,
url=reverse("sas:moderation"),
type="SAS_MODERATION",
param="1",
).save()
def is_owned_by(self, user: User) -> bool: def is_owned_by(self, user: User) -> bool:
if user.is_anonymous: if user.is_anonymous:
@@ -887,6 +910,8 @@ class SithFile(models.Model):
return user.is_board_member return user.is_board_member
if user.is_com_admin: if user.is_com_admin:
return True return True
if self.is_in_sas and user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return True
return user.id == self.owner_id return user.id == self.owner_id
def can_be_viewed_by(self, user: User) -> bool: def can_be_viewed_by(self, user: User) -> bool:
@@ -913,6 +938,8 @@ class SithFile(models.Model):
super().clean() super().clean()
if "/" in self.name: if "/" in self.name:
raise ValidationError(_("Character '/' not authorized in name")) raise ValidationError(_("Character '/' not authorized in name"))
if self == self.parent:
raise ValidationError(_("Loop in folder tree"), code="loop")
if self == self.parent or ( if self == self.parent or (
self.parent is not None and self in self.get_parent_list() self.parent is not None and self in self.get_parent_list()
): ):
@@ -1050,6 +1077,18 @@ class SithFile(models.Model):
def is_file(self): def is_file(self):
return not self.is_folder return not self.is_folder
@cached_property
def as_picture(self):
from sas.models import Picture
return Picture.objects.filter(id=self.id).first()
@cached_property
def as_album(self):
from sas.models import Album
return Album.objects.filter(id=self.id).first()
def get_parent_list(self): def get_parent_list(self):
parents = [] parents = []
current = self.parent current = self.parent
@@ -1151,6 +1190,18 @@ class NotLocked(LockError):
pass pass
class PageQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self:
if user.is_anonymous:
return self.filter(view_groups=settings.SITH_GROUP_PUBLIC_ID)
if user.has_perm("core.view_page"):
return self.all()
groups_ids = [g.id for g in user.cached_groups]
if user.is_subscribed:
groups_ids.append(settings.SITH_GROUP_SUBSCRIBERS_ID)
return self.filter(view_groups__in=groups_ids)
# This function prevents generating migration upon settings change # This function prevents generating migration upon settings change
def get_default_owner_group(): def get_default_owner_group():
return settings.SITH_GROUP_ROOT_ID return settings.SITH_GROUP_ROOT_ID
@@ -1220,6 +1271,8 @@ class Page(models.Model):
_("lock_timeout"), null=True, blank=True, default=None _("lock_timeout"), null=True, blank=True, default=None
) )
objects = PageQuerySet.as_manager()
class Meta: class Meta:
unique_together = ("name", "parent") unique_together = ("name", "parent")
permissions = ( permissions = (
@@ -1229,12 +1282,9 @@ class Page(models.Model):
def __str__(self): def __str__(self):
return self.get_full_name() return self.get_full_name()
def save(self, *args, **kwargs): def save(self, *args, force_lock: bool = False, **kwargs):
"""Performs some needed actions before and after saving a page in database.""" """Performs some needed actions before and after saving a page in database."""
locked = kwargs.pop("force_lock", False) if not force_lock and not self.is_locked():
if not locked:
locked = self.is_locked()
if not locked:
raise NotLocked("The page is not locked and thus can not be saved") raise NotLocked("The page is not locked and thus can not be saved")
self.full_clean() self.full_clean()
if not self.id: if not self.id:
@@ -1246,7 +1296,7 @@ class Page(models.Model):
# It also update all the children to maintain correct names # It also update all the children to maintain correct names
self._full_name = self.get_full_name() self._full_name = self.get_full_name()
for c in self.children.all(): for c in self.children.all():
c.save() c.save(force_lock=force_lock)
super().save(*args, **kwargs) super().save(*args, **kwargs)
self.unset_lock() self.unset_lock()
@@ -1353,23 +1403,23 @@ class Page(models.Model):
@cached_property @cached_property
def is_club_page(self): def is_club_page(self):
club_root_page = Page.objects.filter(name=settings.SITH_CLUB_ROOT_PAGE).first() return (
return club_root_page is not None and ( self.name == settings.SITH_CLUB_ROOT_PAGE
self == club_root_page or club_root_page in self.get_parent_list() or settings.SITH_CLUB_ROOT_PAGE in [p.name for p in self.get_parent_list()]
) )
@cached_property @cached_property
def need_club_redirection(self): def need_club_redirection(self):
return self.is_club_page and self.name != settings.SITH_CLUB_ROOT_PAGE return self.is_club_page and self.name != settings.SITH_CLUB_ROOT_PAGE
def delete(self): def delete(self, *args, **kwargs):
self.unset_lock_recursive() self.unset_lock_recursive()
self.set_lock_recursive(User.objects.get(id=0)) self.set_lock_recursive(User.objects.get(id=0))
for child in self.children.all(): for child in self.children.all():
child.parent = self.parent child.parent = self.parent
child.save() child.save()
child.unset_lock_recursive() child.unset_lock_recursive()
super().delete() return super().delete(*args, **kwargs)
class PageRev(models.Model): class PageRev(models.Model):
@@ -1416,9 +1466,12 @@ class PageRev(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return reverse("core:page", kwargs={"page_name": self.page._full_name}) return reverse("core:page", kwargs={"page_name": self.page._full_name})
def can_be_edited_by(self, user): def can_be_edited_by(self, user: User) -> bool:
return self.page.can_be_edited_by(user) return self.page.can_be_edited_by(user)
def is_owned_by(self, user: User) -> bool:
return any(g.id == self.page.owner_group_id for g in user.cached_groups)
def get_notification_types(): def get_notification_types():
return settings.SITH_NOTIFICATIONS return settings.SITH_NOTIFICATIONS

View File

@@ -34,6 +34,22 @@ class SimpleUserSchema(ModelSchema):
fields = ["id", "nick_name", "first_name", "last_name"] fields = ["id", "nick_name", "first_name", "last_name"]
class UserSchema(ModelSchema):
class Meta:
model = User
fields = [
"id",
"nick_name",
"first_name",
"last_name",
"date_of_birth",
"email",
"role",
"quote",
"promo",
]
class UserProfileSchema(ModelSchema): class UserProfileSchema(ModelSchema):
"""The necessary information to show a user profile""" """The necessary information to show a user profile"""

View File

@@ -1,7 +1,9 @@
import { alpinePlugin as notificationPlugin } from "#core:utils/notifications";
import sort from "@alpinejs/sort"; import sort from "@alpinejs/sort";
import Alpine from "alpinejs"; import Alpine from "alpinejs";
Alpine.plugin(sort); Alpine.plugin(sort);
Alpine.magic("notifications", notificationPlugin);
window.Alpine = Alpine; window.Alpine = Alpine;
window.addEventListener("DOMContentLoaded", () => { window.addEventListener("DOMContentLoaded", () => {

View File

@@ -0,0 +1,36 @@
export enum NotificationLevel {
Error = "error",
Warning = "warning",
Success = "success",
}
export function createNotification(message: string, level: NotificationLevel) {
const element = document.getElementById("quick-notifications");
if (element === null) {
return false;
}
return element.dispatchEvent(
new CustomEvent("quick-notification-add", {
detail: { text: message, tag: level },
}),
);
}
export function deleteNotifications() {
const element = document.getElementById("quick-notifications");
if (element === null) {
return false;
}
return element.dispatchEvent(new CustomEvent("quick-notification-delete"));
}
export function alpinePlugin() {
return {
error: (message: string) => createNotification(message, NotificationLevel.Error),
warning: (message: string) =>
createNotification(message, NotificationLevel.Warning),
success: (message: string) =>
createNotification(message, NotificationLevel.Success),
clear: () => deleteNotifications(),
};
}

View File

@@ -36,6 +36,7 @@
> .ts-control { > .ts-control {
box-shadow: none; box-shadow: none;
max-width: 300px; max-width: 300px;
width: 300px;
background-color: var(--nf-input-background-color); background-color: var(--nf-input-background-color);
&::after { &::after {

View File

@@ -47,6 +47,7 @@
} }
input, input,
select,
textarea[type="text"], textarea[type="text"],
[type="number"], [type="number"],
.ts-control { .ts-control {
@@ -153,11 +154,9 @@ form {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.row { .row > label {
label {
margin: unset; margin: unset;
} }
}
// ------------- LABEL // ------------- LABEL
label, legend { label, legend {
@@ -240,6 +239,23 @@ form {
} }
} }
} }
input[type="text"],
input[type="email"],
input[type="tel"],
input[type="url"],
input[type="password"],
input[type="number"],
input[type="date"],
input[type="datetime-local"],
input[type="week"],
input[type="time"],
input[type="month"],
input[type="search"],
textarea,
select,
.ts-control {
min-height: calc(var(--nf-input-size) * 2.5);
}
input[type="text"], input[type="text"],
input[type="checkbox"], input[type="checkbox"],

View File

@@ -321,7 +321,6 @@ $hovered-red-text-color: #ff4d4d;
>#header_notif { >#header_notif {
box-sizing: border-box; box-sizing: border-box;
display: none;
position: absolute; position: absolute;
margin: 0; margin: 0;
background-color: whitesmoke; background-color: whitesmoke;

View File

@@ -1,38 +0,0 @@
$(() => {
$("#quick_notif li").click(function () {
$(this).hide();
});
});
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
function createQuickNotif(msg) {
const el = document.createElement("li");
el.textContent = msg;
el.addEventListener("click", () => el.parentNode.removeChild(el));
document.getElementById("quick_notif").appendChild(el);
}
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
function deleteQuickNotifs() {
const el = document.getElementById("quick_notif");
while (el.firstChild) {
el.removeChild(el.firstChild);
}
}
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
function displayNotif() {
$("#header_notif").toggle().parent().toggleClass("white");
}
// You can't get the csrf token from the template in a widget
// We get it from a cookie as a workaround, see this link
// https://docs.djangoproject.com/en/2.0/ref/csrf/#ajax
// Sadly, getting the cookie is not possible with CSRF_COOKIE_HTTPONLY or CSRF_USE_SESSIONS is True
// So, the true workaround is to get the token from the dom
// https://docs.djangoproject.com/en/2.0/ref/csrf/#acquiring-the-token-if-csrf-use-sessions-is-true
// biome-ignore lint/style/useNamingConvention: can't find it used anywhere but I will not play with the devil
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
function getCSRFToken() {
return $("[name=csrfmiddlewaretoken]").val();
}

View File

@@ -270,17 +270,6 @@ body {
} }
/*--------------------------------CONTENT------------------------------*/ /*--------------------------------CONTENT------------------------------*/
#quick_notif {
width: 100%;
margin: 0 auto;
list-style-type: none;
background: $second-color;
li {
padding: 10px;
}
}
#content { #content {
padding: 1em 1%; padding: 1em 1%;
box-shadow: $shadow-color 0 5px 10px; box-shadow: $shadow-color 0 5px 10px;
@@ -514,9 +503,17 @@ th {
text-align: center; text-align: center;
padding: 5px 10px; padding: 5px 10px;
>input[type="checkbox"] {
padding: unset;
}
>ul { >ul {
margin-top: 0; margin-top: 0;
} }
>input[type="checkbox"] {
padding: unset;
}
} }
td { td {

View File

@@ -2,8 +2,14 @@
<html lang="fr"> <html lang="fr">
<head> <head>
{% block head %} {% block head %}
<title>{% block title %}{% trans %}Welcome!{% endtrans %}{% endblock %} - Association des Étudiants UTBM</title> <title>{% block title %}Association des Étudiants de l'UTBM{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{% block description -%}
{% trans trimmed %}
AE UTBM is a voluntary organisation run by UTBM students.
It organises student life at UTBM and manages its student facilities.
{% endtrans %}
{%- endblock %}">
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}"> <link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
<link rel="stylesheet" href="{{ static('core/base.css') }}"> <link rel="stylesheet" href="{{ static('core/base.css') }}">
<link rel="stylesheet" href="{{ static('core/style.scss') }}"> <link rel="stylesheet" href="{{ static('core/style.scss') }}">
@@ -26,10 +32,6 @@
<script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script> <script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/core/tooltips-index.ts') }}"></script> <script type="module" src="{{ static('bundled/core/tooltips-index.ts') }}"></script>
<!-- Jquery declared here to be accessible in every django widgets -->
<script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
<script src="{{ static('core/js/script.js') }}"></script>
{% block additional_css %}{% endblock %} {% block additional_css %}{% endblock %}
{% block additional_js %}{% endblock %} {% block additional_js %}{% endblock %}
{% endblock %} {% endblock %}
@@ -68,17 +70,15 @@
<div id="page"> <div id="page">
<ul id="quick_notif">
{% for n in quick_notifs %}
<li>{{ n }}</li>
{% endfor %}
</ul>
<div id="content"> <div id="content">
{%- block tabs -%} {%- block tabs -%}
{% include "core/base/tabs.jinja" %} {% include "core/base/tabs.jinja" %}
{%- endblock -%} {%- endblock -%}
{% block notifications %}
{% include "core/base/notifications.jinja" %}
{% endblock %}
{%- block errors -%} {%- block errors -%}
{% if error %} {% if error %}
{{ error }} {{ error }}
@@ -95,16 +95,6 @@
{% endblock %} {% endblock %}
{% block script %} {% block script %}
<script>
document.addEventListener("keydown", (e) => {
// Looking at the `s` key when not typing in a form
if (e.keyCode !== 83 || ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.nodeName)) {
return;
}
document.getElementById("search").focus();
e.preventDefault(); // Don't type the character in the focused search input
})
</script>
{% endblock %} {% endblock %}
</body> </body>
</html> </html>

View File

@@ -74,25 +74,25 @@
{% endif %} {% endif %}
></a> ></a>
</div> </div>
<div class="notification"> <div class="notification" x-data="{display: false}" :class="{white: display}">
<a href="#" onclick="displayNotif()"> <a href="#" @click.prevent="display = !display">
<i class="fa-regular fa-bell"></i> <i :class="`fa-${display ? 'solid': 'regular'} fa-bell`" x-transition></i>
{% set notification_count = user.notifications.filter(viewed=False).count() %} {% set notifications = user.notifications.filter(viewed=False).order_by("-date")|list %}
{% if notification_count > 0 %} {%- if notifications|length > 0 -%}
<span> <span>
{% if notification_count < 100 %} {% if notifications|length < 100 %}
{{ notification_count }} {{ notifications|length }}
{% else %} {%- else -%}
&nbsp; 99+
{% endif %} {%- endif -%}
</span> </span>
{% endif %} {% endif %}
</a> </a>
<div id="header_notif"> <div id="header_notif" x-show="display" x-cloak x-transition @click.outside="display = false">
<ul> <ul>
{% if user.notifications.filter(viewed=False).count() > 0 %} {%- if notifications|length > 0 -%}
{% for n in user.notifications.filter(viewed=False).order_by('-date') %} {%- for n in notifications -%}
<li> <li>
<a href="{{ url("core:notification", notif_id=n.id) }}"> <a href="{{ url("core:notification", notif_id=n.id) }}">
<div class="datetime"> <div class="datetime">
@@ -108,10 +108,10 @@
</div> </div>
</a> </a>
</li> </li>
{% endfor %} {%- endfor -%}
{% else %} {%- else -%}
<li class="empty-notification">{% trans %}You do not have any unread notification{% endtrans %}</li> <li class="empty-notification">{% trans %}You do not have any unread notification{% endtrans %}</li>
{% endif %} {%- endif -%}
</ul> </ul>
<div class="options"> <div class="options">
<a href="{{ url('core:notification_list') }}"> <a href="{{ url('core:notification_list') }}">

View File

@@ -0,0 +1,24 @@
<div id="quick-notifications"
x-data="{
messages: [
{% if messages %}
{% for message in messages %}
{
tag: '{{ message.tags }}',
text: '{{ message }}',
},
{% endfor %}
{% endif %}
]
}"
@quick-notification-add="(e) => messages.push(e?.detail)"
@quick-notification-delete="messages = []">
<template x-for="(message, index) in messages">
<div class="alert" :class="`alert-${message.tag}`" x-transition>
<span class="alert-main" x-text="message.text"></span>
<span class="clickable" @click="messages = messages.filter((item, i) => i !== index)">
<i class="fa fa-close"></i>
</span>
</div>
</template>
</div>

View File

@@ -15,6 +15,7 @@
{{ select_all_checkbox("add_users") }} {{ select_all_checkbox("add_users") }}
<hr> <hr>
{% csrf_token %} {% csrf_token %}
{{ form.non_field_errors() }}
<label for="{{ form.users_removed.id_for_label }}">{{ form.users_removed.label }} :</label> <label for="{{ form.users_removed.id_for_label }}">{{ form.users_removed.label }} :</label>
{{ form.users_removed.errors }} {{ form.users_removed.errors }}
{% for user in form.users_removed %} {% for user in form.users_removed %}

View File

@@ -245,3 +245,26 @@
<button type="button" onclick="checkbox_{{form_id}}(true);">{% trans %}Select All{% endtrans %}</button> <button type="button" onclick="checkbox_{{form_id}}(true);">{% trans %}Select All{% endtrans %}</button>
<button type="button" onclick="checkbox_{{form_id}}(false);">{% trans %}Unselect All{% endtrans %}</button> <button type="button" onclick="checkbox_{{form_id}}(false);">{% trans %}Unselect All{% endtrans %}</button>
{% endmacro %} {% endmacro %}
{% macro update_notifications(messages, clear) %}
{# Update notification area from new messages sent by django backend
This is useful when performing fragment swaps to keep messages up to date
Without this, the fragment would need to take control of the notification area and
this would be an issue when having more than one fragment
Parameters:
messages: messages from django.contrib
clear : optional boolean that controls if notifications should be cleared first. True is the default
#}
{% set clear = clear|default(true) %}
{% if messages %}
<div x-init="() => {
{% if clear %}
$notifications.clear()
{% endif %}
{% for message in messages %}
$notifications.{{ message.tags }}('{{ message }}')
{% endfor %}
}"></div>
{% endif %}
{% endmacro %}

View File

@@ -5,16 +5,12 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% if page_list %}
<h3>{% trans %}Page list{% endtrans %}</h3> <h3>{% trans %}Page list{% endtrans %}</h3>
<ul> <ul>
{% for p in page_list %} {% for p in page_list %}
<li><a href="{{ p.get_absolute_url() }}">{{ p.get_display_name() }}</a></li> <li><a href="{{ p.get_absolute_url() }}">{{ p.display_name }}</a></li>
{% endfor %} {% endfor %}
</ul> </ul>
{% else %}
{% trans %}There is no page in this website.{% endtrans %}
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -30,7 +30,11 @@
- {{ purchase.date|localtime|time(DATETIME_FORMAT) }} - {{ purchase.date|localtime|time(DATETIME_FORMAT) }}
</td> </td>
<td>{{ purchase.counter }}</td> <td>{{ purchase.counter }}</td>
{% if not purchase.seller %}
<td>{% trans %}Deleted user{% endtrans %}</td>
{% else %}
<td><a href="{{ purchase.seller.get_absolute_url() }}">{{ purchase.seller.get_display_name() }}</a></td> <td><a href="{{ purchase.seller.get_absolute_url() }}">{{ purchase.seller.get_display_name() }}</a></td>
{% endif %}
<td>{{ purchase.label }}</td> <td>{{ purchase.label }}</td>
<td>{{ purchase.quantity }}</td> <td>{{ purchase.quantity }}</td>
<td>{{ purchase.quantity * purchase.unit_price }} €</td> <td>{{ purchase.quantity * purchase.unit_price }} €</td>

View File

@@ -1,12 +1,13 @@
{% for js in statics.js %} {% spaceless %}
{% for js in statics.js %}
<script-once type="module" src="{{ js }}"></script-once> <script-once type="module" src="{{ js }}"></script-once>
{% endfor %} {% endfor %}
{% for css in statics.css %} {% for css in statics.css %}
<link-once rel="stylesheet" type="text/css" href="{{ css }}" defer></link-once> <link-once rel="stylesheet" type="text/css" href="{{ css }}" defer></link-once>
{% endfor %} {% endfor %}
<{{ component }} name="{{ widget.name }}" {% include "django/forms/widgets/attrs.html" %}> <{{ component }} name="{{ widget.name }}" {% include "django/forms/widgets/attrs.html" %}>
{% for group_name, group_choices, group_index in widget.optgroups %} {% for group_name, group_choices, group_index in widget.optgroups %}
{% if group_name %} {% if group_name %}
<optgroup label="{{ group_name }}"> <optgroup label="{{ group_name }}">
{% endif %} {% endif %}
@@ -16,8 +17,9 @@
{% if group_name %} {% if group_name %}
</optgroup> </optgroup>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if initial %} {% if initial %}
<slot style="display:none" name="initial">{{ initial }}</slot> <slot style="display:none" name="initial">{{ initial }}</slot>
{% endif %} {% endif %}
</{{ component }}> </{{ component }}>
{% endspaceless %}

View File

@@ -5,7 +5,6 @@ from typing import Callable
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
from django.test import Client, TestCase from django.test import Client, TestCase
@@ -18,8 +17,8 @@ from pytest_django.asserts import assertNumQueries
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
from core.models import Group, QuickUploadImage, SithFile, User from core.models import Group, QuickUploadImage, SithFile, User
from core.utils import RED_PIXEL_PNG from core.utils import RED_PIXEL_PNG
from sas.baker_recipes import picture_recipe
from sas.models import Picture from sas.models import Picture
from sith import settings
@pytest.mark.django_db @pytest.mark.django_db
@@ -31,19 +30,24 @@ class TestImageAccess:
lambda: baker.make( lambda: baker.make(
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)] User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
), ),
lambda: baker.make(
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)]
),
], ],
) )
def test_sas_image_access(self, user_factory: Callable[[], User]): def test_sas_image_access(self, user_factory: Callable[[], User]):
"""Test that only authorized users can access the sas image.""" """Test that only authorized users can access the sas image."""
user = user_factory() user = user_factory()
picture = picture_recipe.make() picture: SithFile = baker.make(
assert user.can_edit(picture) Picture, parent=SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID)
)
assert picture.is_owned_by(user)
def test_sas_image_access_owner(self): def test_sas_image_access_owner(self):
"""Test that the owner of the image can access it.""" """Test that the owner of the image can access it."""
user = baker.make(User) user = baker.make(User)
picture = picture_recipe.make(owner=user) picture: Picture = baker.make(Picture, owner=user)
assert user.can_edit(picture) assert picture.is_owned_by(user)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"user_factory", "user_factory",
@@ -59,41 +63,7 @@ class TestImageAccess:
user = user_factory() user = user_factory()
owner = baker.make(User) owner = baker.make(User)
picture: Picture = baker.make(Picture, owner=owner) picture: Picture = baker.make(Picture, owner=owner)
assert not user.can_edit(picture) assert not picture.is_owned_by(user)
@pytest.mark.django_db
class TestUserPicture:
def test_anonymous_user_unauthorized(self, client):
"""An anonymous user shouldn't have access to an user's photo page."""
response = client.get(
reverse(
"core:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == 403
@pytest.mark.parametrize(
("username", "status"),
[
("guy", 403),
("root", 200),
("skia", 200),
("sli", 200),
],
)
def test_page_is_working(self, client, username, status):
"""Only user that subscribed (or admins) should be able to see the page."""
# Test for simple user
client.force_login(User.objects.get(username=username))
response = client.get(
reverse(
"core:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == status
# TODO: many tests on the pages: # TODO: many tests on the pages:

58
core/tests/test_page.py Normal file
View File

@@ -0,0 +1,58 @@
import pytest
from django.conf import settings
from django.contrib.auth.models import Permission
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertRedirects
from core.baker_recipes import board_user, subscriber_user
from core.models import AnonymousUser, Page, User
from sith.settings import SITH_GROUP_OLD_SUBSCRIBERS_ID, SITH_GROUP_SUBSCRIBERS_ID
@pytest.mark.django_db
def test_edit_page(client: Client):
user = board_user.make()
page = baker.prepare(Page)
page.save(force_lock=True)
page.view_groups.add(user.groups.first())
client.force_login(user)
url = reverse("core:page_edit", kwargs={"page_name": page._full_name})
res = client.get(url)
assert res.status_code == 200
res = client.post(url, data={"content": "Hello World"})
assertRedirects(res, reverse("core:page", kwargs={"page_name": page._full_name}))
revision = page.revisions.last()
assert revision.content == "Hello World"
@pytest.mark.django_db
def test_viewable_by():
# remove existing pages to prevent side effect
Page.objects.all().delete()
view_groups = [
[settings.SITH_GROUP_PUBLIC_ID],
[settings.SITH_GROUP_PUBLIC_ID, SITH_GROUP_SUBSCRIBERS_ID],
[SITH_GROUP_SUBSCRIBERS_ID],
[SITH_GROUP_SUBSCRIBERS_ID, SITH_GROUP_OLD_SUBSCRIBERS_ID],
[],
]
pages = baker.make(Page, _quantity=len(view_groups), _bulk_create=True)
for page, groups in zip(pages, view_groups, strict=True):
page.view_groups.set(groups)
viewable = Page.objects.viewable_by(AnonymousUser()).values_list("id", flat=True)
assert set(viewable) == {pages[0].id, pages[1].id}
subscriber = subscriber_user.make()
viewable = Page.objects.viewable_by(subscriber).values_list("id", flat=True)
assert set(viewable) == {p.id for p in pages[0:4]}
root_user = baker.make(
User, user_permissions=[Permission.objects.get(codename="view_page")]
)
viewable = Page.objects.viewable_by(root_user).values_list("id", flat=True)
assert set(viewable) == {p.id for p in pages}

View File

@@ -20,9 +20,9 @@ from core.baker_recipes import (
) )
from core.models import Group, User from core.models import Group, User
from core.views import UserTabsMixin from core.views import UserTabsMixin
from counter.models import Counter, Refilling, Selling from counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Refilling, Selling
from eboutic.models import Invoice, InvoiceItem from eboutic.models import Invoice, InvoiceItem
from sas.models import Picture
class TestSearchUsers(TestCase): class TestSearchUsers(TestCase):
@@ -30,7 +30,6 @@ class TestSearchUsers(TestCase):
def setUpTestData(cls): def setUpTestData(cls):
# News.author has on_delete=PROTECT, so news must be deleted beforehand # News.author has on_delete=PROTECT, so news must be deleted beforehand
News.objects.all().delete() News.objects.all().delete()
Picture.objects.all().delete() # same for pictures
User.objects.all().delete() User.objects.all().delete()
user_recipe = Recipe( user_recipe = Recipe(
User, User,
@@ -131,6 +130,31 @@ def test_user_account_not_found(client: Client):
assert res.status_code == 404 assert res.status_code == 404
@pytest.mark.django_db
def test_is_deleted_barman_shown_as_deleted(client: Client):
customer = baker.make(Customer)
date = now()
sale_recipe.make(
seller=iter([None, baker.make(User)]),
customer=customer,
date=date,
_quantity=2,
_bulk_create=True,
)
client.force_login(customer.user)
res = client.get(
reverse(
"core:user_account_detail",
kwargs={
"user_id": customer.user.id,
"year": date.year,
"month": date.month,
},
)
)
assert res.status_code == 200
class TestFilterInactive(TestCase): class TestFilterInactive(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@@ -12,23 +12,18 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from dataclasses import dataclass
from datetime import date, timedelta from datetime import date, timedelta
# Image utils # Image utils
from io import BytesIO from io import BytesIO
from typing import Any, Final, Unpack from typing import Final
import PIL import PIL
from django.conf import settings from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile from django.core.files.uploadedfile import UploadedFile
from django.db import models from django.http import HttpRequest
from django.forms import BaseForm
from django.http import Http404, HttpRequest
from django.shortcuts import get_list_or_404
from django.template.loader import render_to_string
from django.utils.safestring import SafeString
from django.utils.timezone import localdate from django.utils.timezone import localdate
from PIL import ExifTags from PIL import ExifTags
from PIL.Image import Image, Resampling from PIL.Image import Image, Resampling
@@ -47,21 +42,6 @@ to generate a dummy image that is considered valid nonetheless
""" """
@dataclass
class FormFragmentTemplateData[T: BaseForm]:
"""Dataclass used to pre-render form fragments"""
form: T
template: str
context: dict[str, Any]
def render(self, request: HttpRequest) -> SafeString:
# Request is needed for csrf_tokens
return render_to_string(
self.template, context={"form": self.form, **self.context}, request=request
)
def get_start_of_semester(today: date | None = None) -> date: def get_start_of_semester(today: date | None = None) -> date:
"""Return the date of the start of the semester of the given date. """Return the date of the start of the semester of the given date.
If no date is given, return the start date of the current semester. If no date is given, return the start date of the current semester.
@@ -215,56 +195,3 @@ def get_client_ip(request: HttpRequest) -> str | None:
return ip return ip
return None return None
Filterable = models.Model | models.QuerySet | models.Manager
ListFilter = dict[str, list | tuple | set]
def get_list_exact_or_404(klass: Filterable, **kwargs: Unpack[ListFilter]) -> list:
"""Use filter() to return a list of objects from a list of unique keys (like ids)
or raises Http404 if the list has not the same length as the given one.
Work like `get_object_or_404()` but for lists of objects, with some caveats :
- The filter must be a list, a tuple or a set.
- There can't be more than exactly one filter.
- There must be no duplicate in the filter.
- The filter should consist in unique keys (like ids), or it could fail randomly.
klass may be a Model, Manager, or QuerySet object. All other passed
arguments and keyword arguments are used in the filter() query.
Raises:
Http404: If the list is empty or doesn't have as many elements as the keys list.
ValueError: If the first argument is not a Model, Manager, or QuerySet object.
ValueError: If more than one filter is passed.
TypeError: If the given filter is not a list, a tuple or a set.
Examples:
Get all the products with ids 1, 2, 3: ::
products = get_list_exact_or_404(Product, id__in=[1, 2, 3])
Don't work with duplicate ids: ::
products = get_list_exact_or_404(Product, id__in=[1, 2, 3, 3])
# Raises Http404: "The list of keys must contain no duplicates."
"""
if len(kwargs) > 1:
raise ValueError("get_list_exact_or_404() only accepts one filter.")
key, list_filter = next(iter(kwargs.items()))
if not isinstance(list_filter, (list, tuple, set)):
raise TypeError(
f"The given filter must be a list, a tuple or a set, not {type(list_filter)}"
)
if len(list_filter) != len(set(list_filter)):
raise ValueError("The list of keys must contain no duplicates.")
kwargs = {key: list_filter}
obj_list = get_list_or_404(klass, **kwargs)
if len(obj_list) != len(list_filter):
raise Http404(
"The given list of keys doesn't match the number of objects found."
f"Expected {len(list_filter)} items, got {len(obj_list)}."
)
return obj_list

View File

@@ -374,7 +374,7 @@ class FileDeleteView(AllowFragment, CanEditPropMixin, DeleteView):
class FileModerationView(AllowFragment, ListView): class FileModerationView(AllowFragment, ListView):
model = SithFile model = SithFile
template_name = "core/file_moderation.jinja" template_name = "core/file_moderation.jinja"
queryset = SithFile.objects.filter(is_moderated=False) queryset = SithFile.objects.filter(is_moderated=False, is_in_sas=False)
ordering = "id" ordering = "id"
paginate_by = 100 paginate_by = 100

View File

@@ -115,7 +115,7 @@ class SelectUser(TextInput):
def validate_future_timestamp(value: date | datetime): def validate_future_timestamp(value: date | datetime):
if value <= now(): if value <= now():
raise ValueError(_("Ensure this timestamp is set in the future")) raise ValidationError(_("Ensure this timestamp is set in the future"))
class FutureDateTimeField(forms.DateTimeField): class FutureDateTimeField(forms.DateTimeField):

View File

@@ -2,7 +2,6 @@ import copy
import inspect import inspect
from typing import Any, ClassVar, LiteralString, Protocol, Unpack from typing import Any, ClassVar, LiteralString, Protocol, Unpack
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.template.loader import render_to_string from django.template.loader import render_to_string
@@ -41,36 +40,6 @@ class TabedViewMixin(View):
return kwargs return kwargs
class QuickNotifMixin:
quick_notif_list = []
def dispatch(self, request, *arg, **kwargs):
# In some cases, the class can stay instanciated, so we need to reset the list
self.quick_notif_list = []
return super().dispatch(request, *arg, **kwargs)
def get_success_url(self):
ret = super().get_success_url()
if hasattr(self, "quick_notif_url_arg"):
if "?" in ret:
ret += "&" + self.quick_notif_url_arg
else:
ret += "?" + self.quick_notif_url_arg
return ret
def get_context_data(self, **kwargs):
"""Add quick notifications to context."""
kwargs = super().get_context_data(**kwargs)
kwargs["quick_notifs"] = []
for n in self.quick_notif_list:
kwargs["quick_notifs"].append(settings.SITH_QUICK_NOTIF[n])
for key, val in settings.SITH_QUICK_NOTIF.items():
for gk in self.request.GET:
if key == gk:
kwargs["quick_notifs"].append(val)
return kwargs
class AllowFragment: class AllowFragment:
"""Add `is_fragment` to templates. It's only True if the request is emitted by htmx""" """Add `is_fragment` to templates. It's only True if the request is emitted by htmx"""

View File

@@ -12,7 +12,10 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import F, OuterRef, Subquery
from django.db.models.functions import Coalesce
# This file contains all the views that concern the page model # This file contains all the views that concern the page model
from django.forms.models import modelform_factory from django.forms.models import modelform_factory
@@ -40,10 +43,26 @@ class CanEditPagePropMixin(CanEditPropMixin):
return res return res
class PageListView(CanViewMixin, ListView): class PageListView(ListView):
model = Page model = Page
template_name = "core/page_list.jinja" template_name = "core/page_list.jinja"
def get_queryset(self):
return (
Page.objects.viewable_by(self.request.user)
.annotate(
display_name=Coalesce(
Subquery(
PageRev.objects.filter(page=OuterRef("id"))
.order_by("-date")
.values("title")[:1]
),
F("name"),
)
)
.select_related("parent")
)
class PageView(CanViewMixin, DetailView): class PageView(CanViewMixin, DetailView):
model = Page model = Page
@@ -167,7 +186,7 @@ class PageEditViewBase(CanEditMixin, UpdateView):
) )
template_name = "core/pagerev_edit.jinja" template_name = "core/pagerev_edit.jinja"
def get_object(self): def get_object(self, *args, **kwargs):
self.page = Page.get_page_by_full_name(self.kwargs["page_name"]) self.page = Page.get_page_by_full_name(self.kwargs["page_name"])
return self._get_revision() return self._get_revision()

View File

@@ -65,7 +65,7 @@ from core.views.forms import (
UserGroupsForm, UserGroupsForm,
UserProfileForm, UserProfileForm,
) )
from core.views.mixins import QuickNotifMixin, TabedViewMixin, UseFragmentsMixin from core.views.mixins import TabedViewMixin, UseFragmentsMixin
from counter.models import Counter, Refilling, Selling from counter.models import Counter, Refilling, Selling
from eboutic.models import Invoice from eboutic.models import Invoice
from subscription.models import Subscription from subscription.models import Subscription
@@ -564,7 +564,7 @@ class UserUpdateGroupView(UserTabsMixin, CanEditPropMixin, UpdateView):
current_tab = "groups" current_tab = "groups"
class UserToolsView(LoginRequiredMixin, QuickNotifMixin, UserTabsMixin, TemplateView): class UserToolsView(LoginRequiredMixin, UserTabsMixin, TemplateView):
"""Displays the logged user's tools.""" """Displays the logged user's tools."""
template_name = "core/user_tools.jinja" template_name = "core/user_tools.jinja"

View File

@@ -22,6 +22,7 @@ from counter.models import (
Counter, Counter,
Customer, Customer,
Eticket, Eticket,
InvoiceCall,
Permanency, Permanency,
Product, Product,
ProductType, ProductType,
@@ -160,3 +161,11 @@ class CashRegisterSummaryAdmin(SearchModelAdmin):
class EticketAdmin(SearchModelAdmin): class EticketAdmin(SearchModelAdmin):
list_display = ("product", "event_date", "event_title") list_display = ("product", "event_date", "event_title")
search_fields = ("product__name", "event_title") search_fields = ("product__name", "event_title")
@admin.register(InvoiceCall)
class InvoiceCallAdmin(SearchModelAdmin):
list_display = ("club", "month", "is_validated")
search_fields = ("club__name",)
list_filter = (("club", admin.RelatedOnlyFieldListFilter),)
date_hierarchy = "month"

View File

@@ -1,13 +1,26 @@
import json
import math import math
import uuid
from datetime import date
from dateutil.relativedelta import relativedelta
from django import forms from django import forms
from django.db.models import Q from django.db.models import Exists, OuterRef, Q
from django.forms import BaseModelFormSet
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_celery_beat.models import ClockedSchedule
from phonenumber_field.widgets import RegionalPhoneNumberWidget from phonenumber_field.widgets import RegionalPhoneNumberWidget
from club.models import Club
from club.widgets.ajax_select import AutoCompleteSelectClub from club.widgets.ajax_select import AutoCompleteSelectClub
from core.models import User from core.models import User
from core.views.forms import NFCTextInput, SelectDate, SelectDateTime from core.views.forms import (
FutureDateTimeField,
NFCTextInput,
SelectDate,
SelectDateTime,
)
from core.views.widgets.ajax_select import ( from core.views.widgets.ajax_select import (
AutoCompleteSelect, AutoCompleteSelect,
AutoCompleteSelectMultipleGroup, AutoCompleteSelectMultipleGroup,
@@ -19,10 +32,14 @@ from counter.models import (
Counter, Counter,
Customer, Customer,
Eticket, Eticket,
InvoiceCall,
Product, Product,
Refilling, Refilling,
ReturnableProduct, ReturnableProduct,
ScheduledProductAction,
Selling,
StudentCard, StudentCard,
get_product_actions,
) )
from counter.widgets.ajax_select import ( from counter.widgets.ajax_select import (
AutoCompleteSelectMultipleCounter, AutoCompleteSelectMultipleCounter,
@@ -158,7 +175,101 @@ class CounterEditForm(forms.ModelForm):
} }
class ProductEditForm(forms.ModelForm): class ScheduledProductActionForm(forms.ModelForm):
"""Form for automatic product archiving.
The `save` method will update or create tasks using celery-beat.
"""
required_css_class = "required"
prefix = "scheduled"
class Meta:
model = ScheduledProductAction
fields = ["task"]
widgets = {"task": forms.RadioSelect(choices=get_product_actions)}
labels = {"task": _("Action")}
help_texts = {"task": ""}
trigger_at = FutureDateTimeField(
label=_("Date and time of action"), widget=SelectDateTime
)
counters = forms.ModelMultipleChoiceField(
label=_("New counters"),
help_text=_("The selected counters will replace the current ones"),
required=False,
widget=AutoCompleteSelectMultipleCounter,
queryset=Counter.objects.all(),
)
def __init__(self, *args, product: Product, **kwargs):
self.product = product
super().__init__(*args, **kwargs)
if not self.instance._state.adding:
self.fields["trigger_at"].initial = self.instance.clocked.clocked_time
self.fields["counters"].initial = json.loads(self.instance.kwargs).get(
"counters"
)
def clean(self):
if not self.changed_data or "trigger_at" in self.errors:
return super().clean()
if "trigger_at" in self.changed_data:
if not self.instance.clocked_id:
self.instance.clocked = ClockedSchedule(
clocked_time=self.cleaned_data["trigger_at"]
)
else:
self.instance.clocked.clocked_time = self.cleaned_data["trigger_at"]
self.instance.clocked.save()
task_kwargs = {"product_id": self.product.id}
if (
self.cleaned_data["task"] == "counter.tasks.change_counters"
and "counters" in self.changed_data
):
task_kwargs["counters"] = [c.id for c in self.cleaned_data["counters"]]
self.instance.product = self.product
self.instance.kwargs = json.dumps(task_kwargs)
self.instance.name = (
f"{self.cleaned_data['task']} - {self.product} - {uuid.uuid4()}"
)
return super().clean()
class BaseScheduledProductActionFormSet(BaseModelFormSet):
def __init__(self, *args, product: Product, **kwargs):
if product.id:
queryset = (
product.scheduled_actions.filter(
enabled=True, clocked__clocked_time__gt=now()
)
.order_by("clocked__clocked_time")
.select_related("clocked")
)
else:
queryset = ScheduledProductAction.objects.none()
form_kwargs = {"product": product}
super().__init__(*args, queryset=queryset, form_kwargs=form_kwargs, **kwargs)
def delete_existing(self, obj: ScheduledProductAction, commit: bool = True): # noqa FBT001
clocked = obj.clocked
super().delete_existing(obj, commit=commit)
if commit:
clocked.delete()
ScheduledProductActionFormSet = forms.modelformset_factory(
ScheduledProductAction,
ScheduledProductActionForm,
formset=BaseScheduledProductActionFormSet,
absolute_max=None,
can_delete=True,
can_delete_extra=False,
extra=2,
)
class ProductForm(forms.ModelForm):
error_css_class = "error" error_css_class = "error"
required_css_class = "required" required_css_class = "required"
@@ -199,22 +310,21 @@ class ProductEditForm(forms.ModelForm):
queryset=Counter.objects.all(), queryset=Counter.objects.all(),
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, instance=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, instance=instance, **kwargs)
if self.instance.id: if self.instance.id:
self.fields["counters"].initial = self.instance.counters.all() self.fields["counters"].initial = self.instance.counters.all()
self.action_formset = ScheduledProductActionFormSet(
*args, product=self.instance, **kwargs
)
def is_valid(self):
return super().is_valid() and self.action_formset.is_valid()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
ret = super().save(*args, **kwargs) ret = super().save(*args, **kwargs)
if self.fields["counters"].initial: self.instance.counters.set(self.cleaned_data["counters"])
# Remove the product from all counter it was added to self.action_formset.save()
# It will then only be added to selected counters
for counter in self.fields["counters"].initial:
counter.products.remove(self.instance)
counter.save()
for counter in self.cleaned_data["counters"]:
counter.products.add(self.instance)
counter.save()
return ret return ret
@@ -266,7 +376,7 @@ class CloseCustomerAccountForm(forms.Form):
) )
class ProductForm(forms.Form): class BasketProductForm(forms.Form):
quantity = forms.IntegerField(min_value=1, required=True) quantity = forms.IntegerField(min_value=1, required=True)
id = forms.IntegerField(min_value=0, required=True) id = forms.IntegerField(min_value=0, required=True)
@@ -371,5 +481,50 @@ class BaseBasketForm(forms.BaseFormSet):
BasketForm = forms.formset_factory( BasketForm = forms.formset_factory(
ProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1 BasketProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
) )
class InvoiceCallForm(forms.Form):
def __init__(self, *args, month: date, **kwargs):
super().__init__(*args, **kwargs)
self.month = month
self.clubs = list(
Club.objects.filter(
Exists(
Selling.objects.filter(
club=OuterRef("pk"),
date__gte=month,
date__lte=month + relativedelta(months=1),
)
)
).annotate(
validated_invoice=Exists(
InvoiceCall.objects.filter(
club=OuterRef("pk"), month=month, is_validated=True
)
)
)
)
self.fields = {
str(club.id): forms.BooleanField(
required=False, initial=club.validated_invoice
)
for club in self.clubs
}
def save(self):
invoice_calls = [
InvoiceCall(
month=self.month,
club_id=club.id,
is_validated=self.cleaned_data.get(str(club.id), False),
)
for club in self.clubs
]
InvoiceCall.objects.bulk_create(
invoice_calls,
update_conflicts=True,
update_fields=["is_validated"],
unique_fields=["month", "club"],
)

View File

@@ -58,7 +58,7 @@ class Migration(migrations.Migration):
migrations.AddConstraint( migrations.AddConstraint(
model_name="returnableproduct", model_name="returnableproduct",
constraint=models.CheckConstraint( constraint=models.CheckConstraint(
check=models.Q( condition=models.Q(
("product", models.F("returned_product")), _negated=True ("product", models.F("returned_product")), _negated=True
), ),
name="returnableproduct_product_different_from_returned", name="returnableproduct_product_different_from_returned",

View File

@@ -0,0 +1,40 @@
# Generated by Django 5.2.3 on 2025-09-14 11:29
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("counter", "0031_alter_counter_options"),
("django_celery_beat", "0019_alter_periodictasks_options"),
]
operations = [
migrations.CreateModel(
name="ScheduledProductAction",
fields=[
(
"periodictask_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="django_celery_beat.periodictask",
),
),
(
"product",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="scheduled_actions",
to="counter.product",
),
),
],
options={"verbose_name": "Product scheduled action"},
bases=("django_celery_beat.periodictask",),
),
]

View File

@@ -0,0 +1,51 @@
# Generated by Django 5.2.3 on 2025-10-15 21:54
import django.db.models.deletion
from django.db import migrations, models
import counter.models
class Migration(migrations.Migration):
dependencies = [
("club", "0014_alter_club_options_rename_unix_name_club_slug_name_and_more"),
("counter", "0032_scheduledproductaction"),
]
operations = [
migrations.CreateModel(
name="InvoiceCall",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"is_validated",
models.BooleanField(default=False, verbose_name="is validated"),
),
("month", counter.models.MonthField(verbose_name="invoice date")),
(
"club",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="club.club"
),
),
],
options={
"verbose_name": "Invoice call",
"verbose_name_plural": "Invoice calls",
"constraints": [
models.UniqueConstraint(
fields=("club", "month"),
name="counter_invoicecall_unique_club_month",
)
],
},
),
]

View File

@@ -15,6 +15,7 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
import contextlib
import os import os
import random import random
import string import string
@@ -34,6 +35,7 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_celery_beat.models import PeriodicTask
from django_countries.fields import CountryField from django_countries.fields import CountryField
from ordered_model.models import OrderedModel from ordered_model.models import OrderedModel
from phonenumber_field.modelfields import PhoneNumberField from phonenumber_field.modelfields import PhoneNumberField
@@ -445,7 +447,8 @@ class Product(models.Model):
buying_groups = list(self.buying_groups.all()) buying_groups = list(self.buying_groups.all())
if not buying_groups: if not buying_groups:
return True return True
return any(user.is_in_group(pk=group.id) for group in buying_groups) res = any(user.is_in_group(pk=group.id) for group in buying_groups)
return res
@property @property
def profit(self): def profit(self):
@@ -479,7 +482,7 @@ class CounterQuerySet(models.QuerySet):
return self.annotate(has_annotated_barman=Exists(subquery)) return self.annotate(has_annotated_barman=Exists(subquery))
def annotate_is_open(self) -> Self: def annotate_is_open(self) -> Self:
"""Annotate tue queryset with the `is_open` field. """Annotate the queryset with the `is_open` field.
For each counter, if `is_open=True`, then the counter is currently opened. For each counter, if `is_open=True`, then the counter is currently opened.
Else the counter is closed. Else the counter is closed.
@@ -535,13 +538,6 @@ class Counter(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def __getattribute__(self, name: str):
if name == "edit_groups":
return Group.objects.filter(
name=self.club.unix_name + settings.SITH_BOARD_SUFFIX
).all()
return object.__getattribute__(self, name)
def get_absolute_url(self) -> str: def get_absolute_url(self) -> str:
if self.type == "EBOUTIC": if self.type == "EBOUTIC":
return reverse("eboutic:main") return reverse("eboutic:main")
@@ -690,8 +686,10 @@ class Counter(models.Model):
Prices will be annotated Prices will be annotated
""" """
products = self.products.select_related("product_type").prefetch_related( products = (
"buying_groups" self.products.filter(archived=False)
.select_related("product_type")
.prefetch_related("buying_groups")
) )
# Only include age appropriate products # Only include age appropriate products
@@ -1278,7 +1276,7 @@ class ReturnableProduct(models.Model):
verbose_name_plural = _("returnable products") verbose_name_plural = _("returnable products")
constraints = [ constraints = [
models.CheckConstraint( models.CheckConstraint(
check=~Q(product=F("returned_product")), condition=~Q(product=F("returned_product")),
name="returnableproduct_product_different_from_returned", name="returnableproduct_product_different_from_returned",
violation_error_message=_( violation_error_message=_(
"The returnable product cannot be the same as the returned one" "The returnable product cannot be the same as the returned one"
@@ -1362,3 +1360,85 @@ class ReturnableProductBalance(models.Model):
f"return balance of {self.customer} " f"return balance of {self.customer} "
f"for {self.returnable.product_id} : {self.balance}" f"for {self.returnable.product_id} : {self.balance}"
) )
def get_product_actions():
return [
("counter.tasks.archive_product", _("Archiving")),
("counter.tasks.change_counters", _("Counters change")),
]
class ScheduledProductAction(PeriodicTask):
"""Extension of celery-beat tasks dedicated to perform actions on Product."""
product = models.ForeignKey(
Product, related_name="scheduled_actions", on_delete=models.CASCADE
)
class Meta:
verbose_name = _("Product scheduled action")
def __init__(self, *args, **kwargs):
self._meta.get_field("task").choices = get_product_actions()
super().__init__(*args, **kwargs)
def full_clean(self, *args, **kwargs):
self.one_off = True # A product action should occur one time only
return super().full_clean(*args, **kwargs)
def clean_clocked(self):
if not self.clocked:
raise ValidationError(_("Product actions must declare a clocked schedule."))
def validate_unique(self, *args, **kwargs):
# The checks done in PeriodicTask.validate_unique aren't
# adapted in the case of scheduled product action,
# so we skip it and execute directly Model.validate_unique
return super(PeriodicTask, self).validate_unique(*args, **kwargs)
class MonthField(models.DateField):
description = _("Year + month field (day forced to 1)")
default_error_messages = {
"invalid": _(
"%(value)s” value has an invalid date format. It must be "
"in YYYY-MM format."
),
"invalid_date": _(
"%(value)s” value has the correct format (YYYY-MM) "
"but it is an invalid date."
),
}
def to_python(self, value):
if isinstance(value, str):
with contextlib.suppress(ValueError):
# If the string is given as YYYY-mm, try to parse it.
# If it fails, it means that the string may be in the form YYYY-mm-dd
# or in an invalid format.
# Whatever the case, we let Django deal with it
# and raise an error if needed
value = datetime.strptime(value, "%Y-%m")
value = super().to_python(value)
if value is None:
return None
return value.replace(day=1)
class InvoiceCall(models.Model):
is_validated = models.BooleanField(verbose_name=_("is validated"), default=False)
club = models.ForeignKey(Club, on_delete=models.CASCADE)
month = MonthField(verbose_name=_("invoice date"))
class Meta:
verbose_name = _("Invoice call")
verbose_name_plural = _("Invoice calls")
constraints = [
models.UniqueConstraint(
fields=["club", "month"], name="counter_invoicecall_unique_club_month"
)
]
def __str__(self):
return f"invoice call of {self.month} made by {self.club}"

View File

@@ -39,6 +39,7 @@
flex: auto; flex: auto;
margin: 0.2em; margin: 0.2em;
width: 20%; width: 20%;
min-width: 350px;
ul { ul {
list-style-type: none; list-style-type: none;

19
counter/tasks.py Normal file
View File

@@ -0,0 +1,19 @@
# Create your tasks here
from celery import shared_task
from counter.models import Counter, Product
@shared_task
def archive_product(*, product_id: int, **kwargs):
product = Product.objects.get(id=product_id)
product.archived = True
product.save()
@shared_task
def change_counters(*, product_id: int, counters: list[int], **kwargs):
product = Product.objects.get(id=product_id)
counters = Counter.objects.filter(id__in=counters)
product.counters.set(counters)

View File

@@ -67,13 +67,13 @@
<option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option> <option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option>
<option value="ANN">{% trans %}Cancel (ANN){% endtrans %}</option> <option value="ANN">{% trans %}Cancel (ANN){% endtrans %}</option>
</optgroup> </optgroup>
{% for category in categories.keys() %} {%- for category in categories.keys() -%}
<optgroup label="{{ category }}"> <optgroup label="{{ category }}">
{% for product in categories[category] %} {%- for product in categories[category] -%}
<option value="{{ product.id }}">{{ product }}</option> <option value="{{ product.id }}">{{ product }}</option>
{% endfor %} {%- endfor -%}
</optgroup> </optgroup>
{% endfor %} {%- endfor -%}
</counter-product-select> </counter-product-select>
<input type="submit" value="{% trans %}Go{% endtrans %}"/> <input type="submit" value="{% trans %}Go{% endtrans %}"/>

View File

@@ -4,35 +4,49 @@
{% trans %}Invoices call{% endtrans %} {% trans %}Invoices call{% endtrans %}
{% endblock %} {% endblock %}
{% block notifications %}{# Notifications are moved below #}{% endblock %}
{% block content %} {% block content %}
<h3>{% trans date=start_date|date("F Y") %}Invoices call for {{ date }}{% endtrans %}</h3> <h3>{% trans date=start_date|date("F Y") %}Invoices call for {{ date }}{% endtrans %}</h3>
<p>{% trans %}Choose another month: {% endtrans %}</p>
<form method="get" action=""> <form method="get" action="">
<select name="month"> <label for="id_form_other_month">{% trans %}Choose another month: {% endtrans %}</label>
<select name="month" id="id_form_other_month">
{% for m in months %} {% for m in months %}
<option value="{{ m|date("Y-m") }}">{{ m|date("Y-m") }}</option> <option value="{{ m|date("Y-m") }}">{{ m|date("Y-m") }}</option>
{% endfor %} {% endfor %}
</select> </select>
<input type="submit" value="{% trans %}Go{% endtrans %}" /> <input type="submit" value="{% trans %}Go{% endtrans %}" />
</form> </form>
<br> <br>
<p>{% trans %}CB Payments{% endtrans %} : {{ sum_cb }} €</p> <p>{% trans %}CB Payments{% endtrans %} : {{ sum_cb }} €</p>
<br> <br>
{% include "core/base/notifications.jinja" %}
<form method="post" action="">
{% csrf_token %}
<table> <table>
<thead> <thead>
<tr>
<td>{% trans %}Club{% endtrans %}</td> <td>{% trans %}Club{% endtrans %}</td>
<td>{% trans %}Sum{% endtrans %}</td> <td>{% trans %}Sum{% endtrans %}</td>
<td>{% trans %}Validated{% endtrans %}</td>
</tr>
</thead> </thead>
<tbody> <tbody>
{% for i in sums %} {% for invoice in invoices %}
<tr> <tr>
<td>{{ i['club__name'] }}</td> <td>{{ invoice.club__name }}</td>
<td>{{ i['selling_sum'] }} €</td> <td>{{ "%.2f"|format(invoice.selling_sum) }} €</td>
<td>
{{ form[invoice.club_id|string] }}
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<input type="hidden" name="month" value="{{ start_date|date('Y-m') }}">
<button type="submit">{% trans %}Save{% endtrans %}</button>
</form>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,56 @@
{% extends "core/base.jinja" %}
{% block content %}
{% if object %}
<h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2>
{% else %}
<h2>{% trans %}Product creation{% endtrans %}</h2>
{% endif %}
<form method="post">
{% csrf_token %}
{{ form.as_p() }}
<br />
<h3>{% trans %}Automatic actions{% endtrans %}</h3>
<p class="margin-bottom">
<em>
{%- trans trimmed -%}
Automatic actions allows to schedule product changes
ahead of time.
{%- endtrans -%}
</em>
</p>
{{ form.action_formset.management_form }}
{%- for action_form in form.action_formset.forms -%}
<fieldset x-data="{action: '{{ action_form.task.initial }}'}">
{{ action_form.non_field_errors() }}
<div class="row gap-2x margin-bottom">
<div>
{{ action_form.task.errors }}
{{ action_form.task.label_tag() }}
{{ action_form.task|add_attr("x-model=action") }}
</div>
<div>{{ action_form.trigger_at.as_field_group() }}</div>
</div>
<div x-show="action==='counter.tasks.change_counters'" class="margin-bottom">
{{ action_form.counters.as_field_group() }}
</div>
{%- if action_form.DELETE -%}
<div class="row gap">
{{ action_form.DELETE.as_field_group() }}
</div>
{%- endif -%}
{%- for field in action_form.hidden_fields() -%}
{{ field }}
{%- endfor -%}
</fieldset>
{%- if not loop.last -%}
<hr class="margin-bottom">
{%- endif -%}
{%- endfor -%}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endblock %}

View File

@@ -0,0 +1,116 @@
import json
from datetime import timedelta
import pytest
from django.conf import settings
from django.test import Client
from django.urls import reverse
from django.utils.timezone import now
from django_celery_beat.models import ClockedSchedule
from model_bakery import baker
from core.models import Group, User
from counter.baker_recipes import counter_recipe, product_recipe
from counter.forms import ScheduledProductActionForm, ScheduledProductActionFormSet
from counter.models import ScheduledProductAction
@pytest.mark.django_db
def test_edit_product(client: Client):
client.force_login(
baker.make(
User, groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)]
)
)
product = product_recipe.make()
url = reverse("counter:product_edit", kwargs={"product_id": product.id})
res = client.get(url)
assert res.status_code == 200
res = client.post(url, data={})
# This is actually a failure, but we just want to check that
# we don't have a 403 or a 500.
# The actual behaviour will be tested directly on the form.
assert res.status_code == 200
@pytest.mark.django_db
class TestProductActionForm:
def test_single_form_archive(self):
product = product_recipe.make()
trigger_at = now() + timedelta(minutes=10)
form = ScheduledProductActionForm(
product=product,
data={
"scheduled-task": "counter.tasks.archive_product",
"scheduled-trigger_at": trigger_at,
},
)
assert form.is_valid()
instance = form.save()
assert instance.clocked.clocked_time == trigger_at
assert instance.enabled is True
assert instance.one_off is True
assert instance.task == "counter.tasks.archive_product"
assert instance.kwargs == json.dumps({"product_id": product.id})
def test_single_form_change_counters(self):
product = product_recipe.make()
counter = counter_recipe.make()
trigger_at = now() + timedelta(minutes=10)
form = ScheduledProductActionForm(
product=product,
data={
"scheduled-task": "counter.tasks.change_counters",
"scheduled-trigger_at": trigger_at,
"scheduled-counters": [counter.id],
},
)
assert form.is_valid()
instance = form.save()
instance.refresh_from_db()
assert instance.clocked.clocked_time == trigger_at
assert instance.enabled is True
assert instance.one_off is True
assert instance.task == "counter.tasks.change_counters"
assert instance.kwargs == json.dumps(
{"product_id": product.id, "counters": [counter.id]}
)
def test_delete(self):
product = product_recipe.make()
clocked = baker.make(ClockedSchedule, clocked_time=now() + timedelta(minutes=2))
task = baker.make(
ScheduledProductAction,
product=product,
one_off=True,
clocked=clocked,
task="counter.tasks.archive_product",
)
formset = ScheduledProductActionFormSet(product=product)
formset.delete_existing(task)
assert not ScheduledProductAction.objects.filter(id=task.id).exists()
assert not ClockedSchedule.objects.filter(id=clocked.id).exists()
@pytest.mark.django_db
class TestProductActionFormSet:
def test_ok(self):
product = product_recipe.make()
counter = counter_recipe.make()
trigger_at = now() + timedelta(minutes=10)
formset = ScheduledProductActionFormSet(
product=product,
data={
"form-TOTAL_FORMS": "2",
"form-INITIAL_FORMS": "0",
"form-0-task": "counter.tasks.archive_product",
"form-0-trigger_at": trigger_at,
"form-1-task": "counter.tasks.change_counters",
"form-1-trigger_at": trigger_at,
"form-1-counters": [counter.id],
},
)
assert formset.is_valid()
formset.save()
assert ScheduledProductAction.objects.filter(product=product).count() == 2

View File

@@ -583,6 +583,16 @@ class TestCounterClick(TestFullClickBase):
- self.beer.selling_price - self.beer.selling_price
) )
def test_no_fetch_archived_product(self):
counter = baker.make(Counter)
customer = baker.make(Customer)
product_recipe.make(archived=True, counters=[counter])
unarchived_products = product_recipe.make(
archived=False, counters=[counter], _quantity=3
)
customer_products = counter.get_products_for(customer)
assert unarchived_products == customer_products
class TestCounterStats(TestCase): class TestCounterStats(TestCase):
@classmethod @classmethod

View File

@@ -0,0 +1,76 @@
from datetime import date, datetime
import pytest
from dateutil.relativedelta import relativedelta
from django.contrib.auth.models import Permission
from django.core.exceptions import ValidationError
from django.test import Client
from django.urls import reverse
from django.utils.timezone import localdate
from model_bakery import baker
from pytest_django.asserts import assertRedirects
from club.models import Club
from core.models import User
from counter.baker_recipes import sale_recipe
from counter.forms import InvoiceCallForm
from counter.models import Customer, InvoiceCall, Selling
@pytest.mark.django_db
@pytest.mark.parametrize(
"month", [date(2025, 10, 20), "2025-10", datetime(2025, 10, 15, 12, 30)]
)
def test_invoice_date_with_date(month: date | datetime | str):
club = baker.make(Club)
invoice = InvoiceCall.objects.create(club=club, month=month)
invoice.refresh_from_db()
assert not invoice.is_validated
assert invoice.month == date(2025, 10, 1)
@pytest.mark.django_db
def test_invoice_call_invalid_month_string():
club = baker.make(Club)
with pytest.raises(ValidationError):
InvoiceCall.objects.create(club=club, month="2025-13")
@pytest.mark.django_db
@pytest.mark.parametrize("query", [None, {"month": "2025-08"}])
def test_invoice_call_view(client: Client, query: dict | None):
user = baker.make(
User,
user_permissions=[
*Permission.objects.filter(
codename__in=["view_invoicecall", "change_invoicecall"]
)
],
)
client.force_login(user)
url = reverse("counter:invoices_call", query=query)
assert client.get(url).status_code == 200
assertRedirects(client.post(url), url)
@pytest.mark.django_db
def test_invoice_call_form():
Selling.objects.all().delete()
month = localdate() - relativedelta(months=1)
clubs = baker.make(Club, _quantity=2)
recipe = sale_recipe.extend(date=month, customer=baker.make(Customer, amount=10000))
recipe.make(club=clubs[0], quantity=2, unit_price=200)
recipe.make(club=clubs[0], quantity=3, unit_price=5)
recipe.make(club=clubs[1], quantity=20, unit_price=10)
form = InvoiceCallForm(
month=month, data={str(clubs[0].id): True, str(clubs[1].id): False}
)
assert form.is_valid()
form.save()
assert InvoiceCall.objects.filter(
club=clubs[0], month=month, is_validated=True
).exists()
assert InvoiceCall.objects.filter(
club=clubs[1], month=month, is_validated=False
).exists()

View File

@@ -6,14 +6,16 @@ import pytest
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from PIL import Image from PIL import Image
from pytest_django.asserts import assertNumQueries from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club
from core.baker_recipes import board_user, subscriber_user from core.baker_recipes import board_user, subscriber_user
from core.models import Group, User from core.models import Group, User
from counter.forms import ProductForm
from counter.models import Product, ProductType from counter.models import Product, ProductType
@@ -84,3 +86,49 @@ def test_fetch_product_nb_queries(client: Client):
# - 1 for the actual request # - 1 for the actual request
# - 1 to prefetch the related buying_groups # - 1 to prefetch the related buying_groups
client.get(reverse("api:search_products_detailed")) client.get(reverse("api:search_products_detailed"))
class TestCreateProduct(TestCase):
@classmethod
def setUpTestData(cls):
cls.product_type = baker.make(ProductType)
cls.club = baker.make(Club)
cls.data = {
"name": "foo",
"description": "bar",
"product_type": cls.product_type.id,
"club": cls.club.id,
"code": "FOO",
"purchase_price": 1.0,
"selling_price": 1.0,
"special_selling_price": 1.0,
"limit_age": 0,
"form-TOTAL_FORMS": 0,
"form-INITIAL_FORMS": 0,
}
def test_form(self):
form = ProductForm(data=self.data)
assert form.is_valid()
instance = form.save()
assert instance.club == self.club
assert instance.product_type == self.product_type
assert instance.name == "foo"
assert instance.selling_price == 1.0
def test_view(self):
self.client.force_login(
baker.make(
User,
groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)],
)
)
url = reverse("counter:new_product")
response = self.client.get(url)
assert response.status_code == 200
response = self.client.post(url, data=self.data)
assertRedirects(response, reverse("counter:product_list"))
product = Product.objects.last()
assert product.name == "foo"
assert product.club == self.club
assert product.product_type == self.product_type

View File

@@ -32,7 +32,7 @@ from core.utils import get_semester_code, get_start_of_semester
from counter.forms import ( from counter.forms import (
CloseCustomerAccountForm, CloseCustomerAccountForm,
CounterEditForm, CounterEditForm,
ProductEditForm, ProductForm,
ReturnableProductForm, ReturnableProductForm,
) )
from counter.models import ( from counter.models import (
@@ -146,8 +146,8 @@ class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
"""A create view for the admins.""" """A create view for the admins."""
model = Product model = Product
form_class = ProductEditForm form_class = ProductForm
template_name = "core/create.jinja" template_name = "counter/product_form.jinja"
current_tab = "products" current_tab = "products"
@@ -155,9 +155,9 @@ class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
"""An edit view for the admins.""" """An edit view for the admins."""
model = Product model = Product
form_class = ProductEditForm form_class = ProductForm
pk_url_kwarg = "product_id" pk_url_kwarg = "product_id"
template_name = "core/edit.jinja" template_name = "counter/product_form.jinja"
current_tab = "products" current_tab = "products"

View File

@@ -12,77 +12,81 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from datetime import datetime, timedelta from datetime import datetime
from datetime import timezone as tz from urllib.parse import urlencode
from django.db.models import F from dateutil.relativedelta import relativedelta
from django.utils import timezone from django.contrib.auth.mixins import PermissionRequiredMixin
from django.views.generic import TemplateView from django.contrib.messages.views import SuccessMessageMixin
from django.db.models import F, Sum
from django.utils.timezone import localdate, make_aware
from django.utils.translation import gettext_lazy as _
from django.views.generic import FormView
from counter.fields import CurrencyField from counter.forms import InvoiceCallForm
from counter.models import Refilling, Selling from counter.models import Refilling, Selling
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin from counter.views.mixins import CounterAdminTabsMixin
class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView): class InvoiceCallView(
CounterAdminTabsMixin, PermissionRequiredMixin, SuccessMessageMixin, FormView
):
template_name = "counter/invoices_call.jinja" template_name = "counter/invoices_call.jinja"
current_tab = "invoices_call" current_tab = "invoices_call"
permission_required = ["counter.view_invoicecall", "counter.change_invoicecall"]
form_class = InvoiceCallForm
success_message = _("Invoice calls status has been updated.")
def get_month(self):
kwargs = self.request.GET or self.request.POST
if "month" in kwargs:
return make_aware(datetime.strptime(kwargs["month"], "%Y-%m"))
return localdate().replace(day=1) - relativedelta(months=1)
def get_form_kwargs(self):
return super().get_form_kwargs() | {"month": self.get_month()}
def form_valid(self, form):
form.save()
return super().form_valid(form)
def get_success_url(self):
# redirect to the month from which the request is originated
url = self.request.path
kwargs = self.request.GET or self.request.POST
if "month" in kwargs:
query = urlencode({"month": kwargs["month"]})
url += f"?{query}"
return url
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""Add sums to the context.""" """Add sums to the context."""
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["months"] = Selling.objects.datetimes("date", "month", order="DESC") kwargs["months"] = Selling.objects.datetimes("date", "month", order="DESC")
if "month" in self.request.GET: start_date = self.get_month()
start_date = datetime.strptime(self.request.GET["month"], "%Y-%m") end_date = start_date + relativedelta(months=1)
else:
start_date = datetime(
year=timezone.now().year,
month=(timezone.now().month + 10) % 12 + 1,
day=1,
)
start_date = start_date.replace(tzinfo=tz.utc)
end_date = (start_date + timedelta(days=32)).replace(
day=1, hour=0, minute=0, microsecond=0
)
from django.db.models import Case, Sum, When
kwargs["sum_cb"] = sum( kwargs["sum_cb"] = Refilling.objects.filter(
[ payment_method="CARD",
r.amount is_validated=True,
for r in Refilling.objects.filter( date__gte=start_date,
date__lte=end_date,
).aggregate(res=Sum("amount", default=0))["res"]
kwargs["sum_cb"] += (
Selling.objects.filter(
payment_method="CARD", payment_method="CARD",
is_validated=True, is_validated=True,
date__gte=start_date, date__gte=start_date,
date__lte=end_date, date__lte=end_date,
) )
] .annotate(amount=F("unit_price") * F("quantity"))
) .aggregate(res=Sum("amount", default=0))["res"]
kwargs["sum_cb"] += sum(
[
s.quantity * s.unit_price
for s in Selling.objects.filter(
payment_method="CARD",
is_validated=True,
date__gte=start_date,
date__lte=end_date,
)
]
) )
kwargs["start_date"] = start_date kwargs["start_date"] = start_date
kwargs["sums"] = ( kwargs["invoices"] = (
Selling.objects.values("club__name") Selling.objects.filter(date__gte=start_date, date__lt=end_date)
.annotate( .values("club_id", "club__name")
selling_sum=Sum( .annotate(selling_sum=Sum(F("unit_price") * F("quantity")))
Case(
When(
date__gte=start_date,
date__lt=end_date,
then=F("unit_price") * F("quantity"),
),
output_field=CurrencyField(),
)
)
)
.exclude(selling_sum=None) .exclude(selling_sum=None)
.order_by("-selling_sum") .order_by("-selling_sum")
) )

View File

@@ -12,6 +12,15 @@ nouveau logo d'une promo. C'est un processus manuel.
de faire cette opération manuellement, ça prend quelques de faire cette opération manuellement, ça prend quelques
minutes et on est certain de la qualité à la fin. minutes et on est certain de la qualité à la fin.
### avec une commande django
```bash
./manage.py add_promo_logo numero_de_promo chemin_dacces_du_logo
```
options:
* `--force/-f` pour automatiquement écraser les logos de promo avec le même nom.
### manuellement
Les logos de promo sont à manuellement ajouter dans le projet. Les logos de promo sont à manuellement ajouter dans le projet.
Ils se situent dans le dossier `core/static/core/img/`. Ils se situent dans le dossier `core/static/core/img/`.

View File

@@ -4,7 +4,6 @@
heading_level: 3 heading_level: 3
members: members:
- TabedViewMixin - TabedViewMixin
- QuickNotifMixin
- AllowFragment - AllowFragment
- FragmentMixin - FragmentMixin
- UseFragmentsMixin - UseFragmentsMixin

View File

@@ -263,35 +263,3 @@ avec un unique champ permettant de sélectionner des groupes.
Par défaut, seuls les utilisateurs avec la permission Par défaut, seuls les utilisateurs avec la permission
`auth.change_permission` auront accès à ce formulaire `auth.change_permission` auront accès à ce formulaire
(donc, normalement, uniquement les utilisateurs Root). (donc, normalement, uniquement les utilisateurs Root).
```mermaid
sequenceDiagram
participant A as Utilisateur
participant B as ReverseProxy
participant C as MarkdownImage
participant D as Model
A->>B: GET /page/foo
B->>C: GET /page/foo
C-->>B: La page, avec les urls
B-->>A: La page, avec les urls
alt image publique
A->>B: GET markdown/public/2025/img.webp
B-->>A: img.webp
end
alt image privée
A->>B: GET markdown_image/{id}
B->>C: GET markdown_image/{id}
C->>D: user.can_view(image)
alt l'utilisateur a le droit de voir l'image
D-->>C: True
C-->>B: 200 (avec le X-Accel-Redirect)
B-->>A: img.webp
end
alt l'utilisateur n'a pas le droit de l'image
D-->>C: False
C-->>B: 403
B-->>A: 403
end
end
```

View File

@@ -17,7 +17,6 @@ document.addEventListener("alpine:init", () => {
this.$watch("basket", () => { this.$watch("basket", () => {
this.saveBasket(); this.saveBasket();
}); });
// Invalidate basket if a purchase was made // Invalidate basket if a purchase was made
if (lastPurchaseTime !== null && localStorage.basketTimestamp !== undefined) { if (lastPurchaseTime !== null && localStorage.basketTimestamp !== undefined) {
if ( if (

View File

@@ -1,3 +1,5 @@
{% from 'core/macros.jinja' import update_notifications %}
<div id=billing-infos-fragment> <div id=billing-infos-fragment>
<div <div
class="collapse" class="collapse"
@@ -29,14 +31,6 @@
> >
</form> </form>
</div> </div>
<br> <br>
{{ update_notifications(messages) }}
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
</div> </div>

View File

@@ -1,5 +1,9 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% block notifications %}
{# Notifications are moved under the billing form #}
{% endblock %}
{% block title %} {% block title %}
{% trans %}Basket state{% endtrans %} {% trans %}Basket state{% endtrans %}
{% endblock %} {% endblock %}
@@ -56,6 +60,7 @@
<div @htmx:after-request="fill"> <div @htmx:after-request="fill">
{{ billing_infos_form }} {{ billing_infos_form }}
</div> </div>
{% include "core/base/notifications.jinja" %}
<form <form
method="post" method="post"
action="{{ settings.SITH_EBOUTIC_ET_URL }}" action="{{ settings.SITH_EBOUTIC_ET_URL }}"

View File

@@ -1,8 +1,12 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% block title %} {% block title -%}
{% trans %}Eboutic{% endtrans %} {% trans %}Eboutic{% endtrans %}
{% endblock %} {%- endblock %}
{% block description -%}
{% trans %}The online shop of the association.{% endtrans %}
{%- endblock %}
{% block additional_js %} {% block additional_js %}
{# This script contains the code to perform requests to manipulate the {# This script contains the code to perform requests to manipulate the
@@ -18,14 +22,6 @@
{% block content %} {% block content %}
<h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1> <h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1>
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
<div id="eboutic" x-data="basket({{ last_purchase_time }})"> <div id="eboutic" x-data="basket({{ last_purchase_time }})">
<div id="basket"> <div id="basket">
<h3>Panier</h3> <h3>Panier</h3>

View File

@@ -4,14 +4,6 @@
<h3>{% trans %}Eboutic{% endtrans %}</h3> <h3>{% trans %}Eboutic{% endtrans %}</h3>
<div> <div>
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% if success %} {% if success %}
{% trans %}Payment successful{% endtrans %} {% trans %}Payment successful{% endtrans %}
{% else %} {% else %}

View File

@@ -1,3 +1,5 @@
from datetime import datetime, timezone
import pytest import pytest
from django.http import HttpResponse from django.http import HttpResponse
from django.test import TestCase from django.test import TestCase
@@ -9,8 +11,13 @@ from pytest_django.asserts import assertRedirects
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import Group, User from core.models import Group, User
from counter.baker_recipes import product_recipe from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
from counter.models import Counter, ProductType, get_eboutic from counter.models import (
Counter,
Customer,
ProductType,
get_eboutic,
)
from counter.tests.test_counter import BasketItem from counter.tests.test_counter import BasketItem
from eboutic.models import Basket from eboutic.models import Basket
@@ -24,6 +31,96 @@ def test_get_eboutic():
assert Counter.objects.get(name="Eboutic") == get_eboutic() assert Counter.objects.get(name="Eboutic") == get_eboutic()
@pytest.mark.django_db
def test_eboutic_access_unregistered(client: Client):
eboutic_url = reverse("eboutic:main")
assertRedirects(
client.get(eboutic_url), reverse("core:login", query={"next": eboutic_url})
)
@pytest.mark.django_db
def test_eboutic_access_new_customer(client: Client):
user = baker.make(User)
assert not Customer.objects.filter(user=user).exists()
client.force_login(user)
assert client.get(reverse("eboutic:main")).status_code == 200
assert Customer.objects.filter(user=user).exists()
@pytest.mark.django_db
def test_eboutic_access_old_customer(client: Client):
user = baker.make(User)
customer = Customer.get_or_create(user)[0]
client.force_login(user)
assert client.get(reverse("eboutic:main")).status_code == 200
assert Customer.objects.filter(user=user).first() == customer
@pytest.mark.django_db
@pytest.mark.parametrize(
("sellings", "refillings", "expected"),
(
([], [], None),
(
[datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc)],
[],
datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc),
),
(
[],
[datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc)],
datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc),
),
(
[datetime(2025, 2, 7, 1, 2, 3, tzinfo=timezone.utc)],
[datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc)],
datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc),
),
(
[datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc)],
[datetime(2025, 2, 7, 1, 2, 3, tzinfo=timezone.utc)],
datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc),
),
(
[
datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc),
datetime(2025, 2, 7, 1, 2, 3, tzinfo=timezone.utc),
],
[datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc)],
datetime(2025, 3, 7, 1, 2, 3, tzinfo=timezone.utc),
),
),
)
def test_eboutic_basket_expiry(
client: Client,
sellings: list[datetime],
refillings: list[datetime],
expected: datetime | None,
):
eboutic = get_eboutic()
customer = baker.make(Customer)
client.force_login(customer.user)
for date in sellings:
sale_recipe.make(
customer=customer, counter=eboutic, date=date, is_validated=True
)
for date in refillings:
refill_recipe.make(customer=customer, counter=eboutic, date=date)
assert (
f'x-data="basket({int(expected.timestamp() * 1000) if expected else "null"})"'
in client.get(reverse("eboutic:main")).text
)
class TestEboutic(TestCase): class TestEboutic(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@@ -34,6 +34,7 @@ from django.contrib.auth.mixins import (
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import SuspiciousOperation, ValidationError from django.core.exceptions import SuspiciousOperation, ValidationError
from django.db import DatabaseError, transaction from django.db import DatabaseError, transaction
from django.db.models import Subquery
from django.db.models.fields import forms from django.db.models.fields import forms
from django.db.utils import cached_property from django.db.utils import cached_property
from django.http import HttpResponse from django.http import HttpResponse
@@ -47,8 +48,15 @@ from django_countries.fields import Country
from core.auth.mixins import CanViewMixin from core.auth.mixins import CanViewMixin
from core.views.mixins import FragmentMixin, UseFragmentsMixin from core.views.mixins import FragmentMixin, UseFragmentsMixin
from counter.forms import BaseBasketForm, BillingInfoForm, ProductForm from counter.forms import BaseBasketForm, BasketProductForm, BillingInfoForm
from counter.models import BillingInfo, Customer, Product, Selling, get_eboutic from counter.models import (
BillingInfo,
Customer,
Product,
Refilling,
Selling,
get_eboutic,
)
from eboutic.models import ( from eboutic.models import (
Basket, Basket,
BasketItem, BasketItem,
@@ -70,7 +78,7 @@ class BaseEbouticBasketForm(BaseBasketForm):
EbouticBasketForm = forms.formset_factory( EbouticBasketForm = forms.formset_factory(
ProductForm, formset=BaseEbouticBasketForm, absolute_max=None, min_num=1 BasketProductForm, formset=BaseEbouticBasketForm, absolute_max=None, min_num=1
) )
@@ -124,13 +132,36 @@ class EbouticMainView(LoginRequiredMixin, FormView):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["products"] = self.products context["products"] = self.products
context["customer_amount"] = self.request.user.account_balance context["customer_amount"] = self.request.user.account_balance
last_purchase: Selling | None = (
self.customer.buyings.filter(counter__type="EBOUTIC") purchases = (
.order_by("-date") Customer.objects.filter(pk=self.customer.pk)
.first() .annotate(
last_refill=Subquery(
Refilling.objects.filter(
counter__type="EBOUTIC", customer_id=self.customer.pk
) )
.order_by("-date")
.values("date")[:1]
),
last_purchase=Subquery(
Selling.objects.filter(
counter__type="EBOUTIC", customer_id=self.customer.pk
)
.order_by("-date")
.values("date")[:1]
),
)
.values_list("last_refill", "last_purchase")
)[0]
purchase_times = [
int(purchase.timestamp() * 1000)
for purchase in purchases
if purchase is not None
]
context["last_purchase_time"] = ( context["last_purchase_time"] = (
int(last_purchase.date.timestamp() * 1000) if last_purchase else "null" max(purchase_times) if len(purchase_times) > 0 else "null"
) )
return context return context

View File

@@ -2,9 +2,13 @@
{% from 'core/macros.jinja' import user_profile_link %} {% from 'core/macros.jinja' import user_profile_link %}
{% from 'forum/macros.jinja' import display_forum, display_search_bar %} {% from 'forum/macros.jinja' import display_forum, display_search_bar %}
{% block title %} {% block title -%}
{% trans %}Forum{% endtrans %} {% trans %}Forum{% endtrans %}
{% endblock %} {%- endblock %}
{% block description -%}
{% trans %}A forum dedicated to the UTBM students.{% endtrans %}
{%- endblock %}
{% block additional_css %} {% block additional_css %}
<link rel="stylesheet" href="{{ static('forum/css/forum.scss') }}"> <link rel="stylesheet" href="{{ static('forum/css/forum.scss') }}">

Some files were not shown because too many files have changed in this diff Show More