Compare commits

..

19 Commits

Author SHA1 Message Date
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
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
34 changed files with 942 additions and 102 deletions

View File

@@ -31,11 +31,7 @@ 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 Q, 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
@@ -55,12 +51,7 @@ from club.forms import (
MailingForm, MailingForm,
SellingsForm, SellingsForm,
) )
from club.models import ( from club.models import Club, Mailing, MailingSubscription, Membership
Club,
Mailing,
MailingSubscription,
Membership,
)
from com.models import Poster from com.models import Poster
from com.views import ( from com.views import (
PosterCreateBaseView, PosterCreateBaseView,
@@ -68,9 +59,7 @@ from com.views import (
PosterEditBaseView, PosterEditBaseView,
PosterListBaseView, PosterListBaseView,
) )
from core.auth.mixins import ( from core.auth.mixins import CanEditMixin
CanEditMixin,
)
from core.models import PageRev from core.models import PageRev
from core.views import DetailFormView, PageEditViewBase, UseFragmentsMixin from core.views import DetailFormView, PageEditViewBase, UseFragmentsMixin
from core.views.mixins import FragmentMixin, FragmentRenderer, TabedViewMixin from core.views.mixins import FragmentMixin, FragmentRenderer, TabedViewMixin

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

@@ -205,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

@@ -651,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
@@ -662,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

View File

@@ -154,10 +154,8 @@ form {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.row { .row > label {
label { margin: unset;
margin: unset;
}
} }
// ------------- LABEL // ------------- LABEL

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

@@ -1,13 +1,23 @@
import json
import math import math
import uuid
from django import forms from django import forms
from django.db.models import Q from django.db.models import 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.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,
@@ -22,7 +32,9 @@ from counter.models import (
Product, Product,
Refilling, Refilling,
ReturnableProduct, ReturnableProduct,
ScheduledProductAction,
StudentCard, StudentCard,
get_product_actions,
) )
from counter.widgets.ajax_select import ( from counter.widgets.ajax_select import (
AutoCompleteSelectMultipleCounter, AutoCompleteSelectMultipleCounter,
@@ -158,7 +170,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 +305,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 +371,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 +476,5 @@ 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
) )

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

@@ -34,6 +34,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 +446,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 +481,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.
@@ -1357,3 +1359,39 @@ 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)

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

@@ -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

@@ -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

@@ -48,7 +48,7 @@ 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 ( from counter.models import (
BillingInfo, BillingInfo,
Customer, Customer,
@@ -78,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
) )

View File

@@ -117,7 +117,7 @@ msgstr "S'abonner"
msgid "Remove" msgid "Remove"
msgstr "Retirer" msgstr "Retirer"
#: club/forms.py pedagogy/templates/pedagogy/moderation.jinja #: club/forms.py counter/forms.py pedagogy/templates/pedagogy/moderation.jinja
msgid "Action" msgid "Action"
msgstr "Action" msgstr "Action"
@@ -556,6 +556,7 @@ msgstr ""
#: core/templates/core/user_godfathers_tree.jinja #: core/templates/core/user_godfathers_tree.jinja
#: core/templates/core/user_preferences.jinja #: core/templates/core/user_preferences.jinja
#: counter/templates/counter/cash_register_summary.jinja #: counter/templates/counter/cash_register_summary.jinja
#: counter/templates/counter/product_form.jinja
#: forum/templates/forum/reply.jinja #: forum/templates/forum/reply.jinja
#: subscription/templates/subscription/fragments/creation_form.jinja #: subscription/templates/subscription/fragments/creation_form.jinja
#: trombi/templates/trombi/comment.jinja #: trombi/templates/trombi/comment.jinja
@@ -1061,6 +1062,10 @@ msgstr "Nos services"
msgid "UV Guide" msgid "UV Guide"
msgstr "Guide des UVs" msgstr "Guide des UVs"
#: com/templates/com/news_list.jinja
msgid "Timetable"
msgstr "Emploi du temps"
#: com/templates/com/news_list.jinja core/templates/core/base/navbar.jinja #: com/templates/com/news_list.jinja core/templates/core/base/navbar.jinja
msgid "Matmatronch" msgid "Matmatronch"
msgstr "Matmatronch" msgstr "Matmatronch"
@@ -2951,6 +2956,18 @@ msgstr "Cet UID est invalide"
msgid "User not found" msgid "User not found"
msgstr "Utilisateur non trouvé" msgstr "Utilisateur non trouvé"
#: counter/forms.py
msgid "Date and time of action"
msgstr "Date et heure de l'action"
#: counter/forms.py
msgid "New counters"
msgstr "Nouveaux comptoirs"
#: counter/forms.py
msgid "The selected counters will replace the current ones"
msgstr "Les comptoirs sélectionnés remplaceront les comptoirs actuels"
#: counter/forms.py #: counter/forms.py
msgid "" msgid ""
"Describe the product. If it's an event's click, give some insights about it, " "Describe the product. If it's an event's click, give some insights about it, "
@@ -3285,6 +3302,18 @@ msgid "The returnable product cannot be the same as the returned one"
msgstr "" msgstr ""
"Le produit consigné ne peut pas être le même que le produit de déconsigne" "Le produit consigné ne peut pas être le même que le produit de déconsigne"
#: counter/models.py
msgid "Archiving"
msgstr "Archivage"
#: counter/models.py
msgid "Counters change"
msgstr "Changement des comptoirs"
#: counter/models.py
msgid "Product scheduled action"
msgstr "Actions sur produit planifiées"
#: counter/templates/counter/activity.jinja #: counter/templates/counter/activity.jinja
#, python-format #, python-format
msgid "%(counter_name)s activity" msgid "%(counter_name)s activity"
@@ -3603,6 +3632,25 @@ msgstr ""
"votre cotisation. Si vous ne renouvelez pas votre cotisation, il n'y aura " "votre cotisation. Si vous ne renouvelez pas votre cotisation, il n'y aura "
"aucune conséquence autre que le retrait de l'argent de votre compte." "aucune conséquence autre que le retrait de l'argent de votre compte."
#: counter/templates/counter/product_form.jinja
#, python-format
msgid "Edit product %(name)s"
msgstr "Édition du produit %(name)s"
#: counter/templates/counter/product_form.jinja
msgid "Product creation"
msgstr "Création de produit"
#: counter/templates/counter/product_form.jinja
msgid "Automatic actions"
msgstr "Actions automatiques"
#: counter/templates/counter/product_form.jinja
msgid "Automatic actions allows to schedule product changes ahead of time."
msgstr ""
"Les actions automatiques vous permettent de planifier des modifications du "
"produit à l'avance."
#: counter/templates/counter/product_list.jinja #: counter/templates/counter/product_list.jinja
msgid "Product list" msgid "Product list"
msgstr "Liste des produits" msgstr "Liste des produits"
@@ -4974,47 +5022,47 @@ msgstr "Suppression de rechargement"
#: sith/settings.py #: sith/settings.py
msgid "One semester" msgid "One semester"
msgstr "Un semestre" msgstr "Un semestre, 20 €"
#: sith/settings.py #: sith/settings.py
msgid "Two semesters" msgid "Two semesters"
msgstr "Deux semestres" msgstr "Deux semestres, 35 €"
#: sith/settings.py #: sith/settings.py
msgid "Common core cursus" msgid "Common core cursus"
msgstr "Cursus tronc commun" msgstr "Cursus tronc commun, 60 €"
#: sith/settings.py #: sith/settings.py
msgid "Branch cursus" msgid "Branch cursus"
msgstr "Cursus branche" msgstr "Cursus branche, 60 €"
#: sith/settings.py #: sith/settings.py
msgid "Alternating cursus" msgid "Alternating cursus"
msgstr "Cursus alternant" msgstr "Cursus alternant, 30 €"
#: sith/settings.py #: sith/settings.py
msgid "Honorary member" msgid "Honorary member"
msgstr "Membre honoraire" msgstr "Membre honoraire, 0 €"
#: sith/settings.py #: sith/settings.py
msgid "Assidu member" msgid "Assidu member"
msgstr "Membre d'Assidu" msgstr "Membre d'Assidu, 0 €"
#: sith/settings.py #: sith/settings.py
msgid "Amicale/DOCEO member" msgid "Amicale/DOCEO member"
msgstr "Membre de l'Amicale/DOCEO" msgstr "Membre de l'Amicale/DOCEO, 0 €"
#: sith/settings.py #: sith/settings.py
msgid "UT network member" msgid "UT network member"
msgstr "Cotisant du réseau UT" msgstr "Cotisant du réseau UT, 0 €"
#: sith/settings.py #: sith/settings.py
msgid "CROUS member" msgid "CROUS member"
msgstr "Membres du CROUS" msgstr "Membres du CROUS, 0 €"
#: sith/settings.py #: sith/settings.py
msgid "Sbarro/ESTA member" msgid "Sbarro/ESTA member"
msgstr "Membre de Sbarro ou de l'ESTA" msgstr "Membre de Sbarro ou de l'ESTA, 20 €"
#: sith/settings.py #: sith/settings.py
msgid "One semester Welcome Week" msgid "One semester Welcome Week"
@@ -5041,28 +5089,28 @@ msgid "One day"
msgstr "Un jour" msgstr "Un jour"
#: sith/settings.py #: sith/settings.py
msgid "GA staff member (2 weeks)" msgid "GA staff member"
msgstr "Membre staff GA (2 semaines)" msgstr "Membre staff GA (2 semaines), 1 €"
#: sith/settings.py #: sith/settings.py
msgid "One semester (-20%)" msgid "One semester (-20%)"
msgstr "Un semestre (-20%)" msgstr "Un semestre (-20%), 12 €"
#: sith/settings.py #: sith/settings.py
msgid "Two semesters (-20%)" msgid "Two semesters (-20%)"
msgstr "Deux semestres (-20%)" msgstr "Deux semestres (-20%), 22 €"
#: sith/settings.py #: sith/settings.py
msgid "Common core cursus (-20%)" msgid "Common core cursus (-20%)"
msgstr "Cursus tronc commun (-20%)" msgstr "Cursus tronc commun (-20%), 36 €"
#: sith/settings.py #: sith/settings.py
msgid "Branch cursus (-20%)" msgid "Branch cursus (-20%)"
msgstr "Cursus branche (-20%)" msgstr "Cursus branche (-20%), 36 €"
#: sith/settings.py #: sith/settings.py
msgid "Alternating cursus (-20%)" msgid "Alternating cursus (-20%)"
msgstr "Cursus alternant (-20%)" msgstr "Cursus alternant (-20%), 24 €"
#: sith/settings.py #: sith/settings.py
msgid "One year for free(CA offer)" msgid "One year for free(CA offer)"
@@ -5236,6 +5284,18 @@ msgstr "Membre existant"
msgid "the groups that can create subscriptions" msgid "the groups that can create subscriptions"
msgstr "les groupes pouvant créer des cotisations" msgstr "les groupes pouvant créer des cotisations"
#: timetable/templates/timetable/generator.jinja
msgid "Timetable generator"
msgstr "Générateur d'emploi du temps"
#: timetable/templates/timetable/generator.jinja
msgid "Generate"
msgstr "Générer"
#: timetable/templates/timetable/generator.jinja
msgid "Save to PNG"
msgstr "Sauver en PNG"
#: trombi/models.py #: trombi/models.py
msgid "subscription deadline" msgid "subscription deadline"
msgstr "fin des inscriptions" msgstr "fin des inscriptions"

50
package-lock.json generated
View File

@@ -29,6 +29,7 @@
"d3-force-3d": "^3.0.5", "d3-force-3d": "^3.0.5",
"easymde": "^2.19.0", "easymde": "^2.19.0",
"glob": "^11.0.0", "glob": "^11.0.0",
"html2canvas": "^1.4.1",
"htmx.org": "^2.0.3", "htmx.org": "^2.0.3",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lit-html": "^3.3.0", "lit-html": "^3.3.0",
@@ -3105,6 +3106,15 @@
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
} }
}, },
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -3493,6 +3503,15 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/cytoscape": { "node_modules/cytoscape": {
"version": "3.33.1", "version": "3.33.1",
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
@@ -4165,6 +4184,19 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/htmx.org": { "node_modules/htmx.org": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.6.tgz", "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.6.tgz",
@@ -5454,6 +5486,15 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/three": { "node_modules/three": {
"version": "0.177.0", "version": "0.177.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.177.0.tgz", "resolved": "https://registry.npmjs.org/three/-/three-0.177.0.tgz",
@@ -5711,6 +5752,15 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "6.3.6", "version": "6.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",

View File

@@ -59,6 +59,7 @@
"d3-force-3d": "^3.0.5", "d3-force-3d": "^3.0.5",
"easymde": "^2.19.0", "easymde": "^2.19.0",
"glob": "^11.0.0", "glob": "^11.0.0",
"html2canvas": "^1.4.1",
"htmx.org": "^2.0.3", "htmx.org": "^2.0.3",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lit-html": "^3.3.0", "lit-html": "^3.3.0",

View File

@@ -125,6 +125,7 @@ INSTALLED_APPS = (
"pedagogy", "pedagogy",
"galaxy", "galaxy",
"antispam", "antispam",
"timetable",
"api", "api",
) )
@@ -541,7 +542,7 @@ SITH_SUBSCRIPTIONS = {
"duration": 4, "duration": 4,
}, },
"cursus-branche": {"name": _("Branch cursus"), "price": 60, "duration": 6}, "cursus-branche": {"name": _("Branch cursus"), "price": 60, "duration": 6},
"cursus-alternant": {"name": _("Alternating cursus"), "price": 35, "duration": 6}, "cursus-alternant": {"name": _("Alternating cursus"), "price": 30, "duration": 6},
"membre-honoraire": {"name": _("Honorary member"), "price": 0, "duration": 666}, "membre-honoraire": {"name": _("Honorary member"), "price": 0, "duration": 666},
"assidu": {"name": _("Assidu member"), "price": 0, "duration": 2}, "assidu": {"name": _("Assidu member"), "price": 0, "duration": 2},
"amicale/doceo": {"name": _("Amicale/DOCEO member"), "price": 0, "duration": 2}, "amicale/doceo": {"name": _("Amicale/DOCEO member"), "price": 0, "duration": 2},
@@ -553,6 +554,8 @@ SITH_SUBSCRIPTIONS = {
"price": 0, "price": 0,
"duration": 1, "duration": 1,
}, },
"un-mois-essai": {"name": _("One month for free"), "price": 0, "duration": 0.166},
"deux-mois-essai": {"name": _("Two months for free"), "price": 0, "duration": 0.33},
"benevoles-euroks": {"name": _("Eurok's volunteer"), "price": 5, "duration": 0.1}, "benevoles-euroks": {"name": _("Eurok's volunteer"), "price": 5, "duration": 0.1},
"six-semaines-essai": { "six-semaines-essai": {
"name": _("Six weeks for free"), "name": _("Six weeks for free"),

View File

@@ -53,6 +53,7 @@ urlpatterns = [
path("i18n/", include("django.conf.urls.i18n")), path("i18n/", include("django.conf.urls.i18n")),
path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"), path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"),
path("captcha/", include("captcha.urls")), path("captcha/", include("captcha.urls")),
path("edt/", include(("timetable.urls", "timetable"), namespace="timetable")),
] ]
if settings.DEBUG: if settings.DEBUG:

View File

@@ -1,21 +0,0 @@
# Generated by Django 5.2.3 on 2025-10-06 11:24
from django.db import migrations, models
import subscription.models
class Migration(migrations.Migration):
dependencies = [("subscription", "0015_alter_subscription_location_and_more")]
operations = [
migrations.AlterField(
model_name="subscription",
name="subscription_type",
field=models.CharField(
choices=subscription.models.get_subscription_types,
max_length=255,
verbose_name="subscription type",
),
)
]

View File

@@ -38,19 +38,16 @@ def validate_payment(value):
raise ValidationError(_("Bad payment method")) raise ValidationError(_("Bad payment method"))
def get_subscription_types():
return (
(k, f"{v['name']}, {v['price']}")
for k, v in sorted(settings.SITH_SUBSCRIPTIONS.items())
)
class Subscription(models.Model): class Subscription(models.Model):
member = models.ForeignKey( member = models.ForeignKey(
User, related_name="subscriptions", on_delete=models.CASCADE User, related_name="subscriptions", on_delete=models.CASCADE
) )
subscription_type = models.CharField( subscription_type = models.CharField(
_("subscription type"), max_length=255, choices=get_subscription_types _("subscription type"),
max_length=255,
choices=(
(k, v["name"]) for k, v in sorted(settings.SITH_SUBSCRIPTIONS.items())
),
) )
subscription_start = models.DateField(_("subscription start")) subscription_start = models.DateField(_("subscription start"))
subscription_end = models.DateField(_("subscription end")) subscription_end = models.DateField(_("subscription end"))

0
timetable/__init__.py Normal file
View File

1
timetable/admin.py Normal file
View File

@@ -0,0 +1 @@
# Register your models here.

6
timetable/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class TimetableConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "timetable"

View File

1
timetable/models.py Normal file
View File

@@ -0,0 +1 @@
# Create your models here.

View File

@@ -0,0 +1,184 @@
import html2canvas from "html2canvas";
// see https://regex101.com/r/QHSaPM/2
const TIMETABLE_ROW_RE: RegExp =
/^(?<ueCode>\w.+\w)\s+(?<courseType>[A-Z]{2}\d)\s+((?<weekGroup>[AB])\s+)?(?<weekday>(lundi)|(mardi)|(mercredi)|(jeudi)|(vendredi)|(samedi)|(dimanche))\s+(?<startHour>\d{2}:\d{2})\s+(?<endHour>\d{2}:\d{2})\s+[\dA-B]\s+((?<attendance>[\wé]*)\s+)?(?<room>\w+(?:, \w+)?)$/;
const DEFAULT_TIMETABLE: string = `DS52\t\tCM1\t\tlundi\t08:00\t10:00\t1\tPrésentiel\tA113
DS53\t\tCM1\t\tlundi\t10:15\t12:15\t1\tPrésentiel\tA101
DS53\t\tTP1\t\tlundi\t13:00\t16:00\t1\tPrésentiel\tH010
SO03\t\tCM1\t\tlundi\t16:15\t17:45\t1\tPrésentiel\tA103
SO03\t\tTD1\t\tlundi\t17:45\t19:45\t1\tPrésentiel\tA103
DS50\t\tTP1\t\tmardi\t08:00\t10:00\t1\tPrésentiel\tA216
DS51\t\tCM1\t\tmardi\t10:15\t12:15\t1\tPrésentiel\tA216
DS51\t\tTP1\t\tmardi\t14:00\t18:00\t1\tPrésentiel\tH010
DS52\t\tTP2\tA\tjeudi\t08:00\t10:00\tA\tPrésentiel\tA110a, A110b
DS52\t\tTD1\t\tjeudi\t10:15\t12:15\t1\tPrésentiel\tA110a, A110b
LC02\t\tTP1\t\tjeudi\t15:00\t16:00\t1\tPrésentiel\tA209
LC02\t\tTD1\t\tjeudi\t16:15\t18:15\t1\tPrésentiel\tA206`;
type WeekDay =
| "lundi"
| "mardi"
| "mercredi"
| "jeudi"
| "vendredi"
| "samedi"
| "dimanche";
const WEEKDAYS = [
"lundi",
"mardi",
"mercredi",
"jeudi",
"vendredi",
"samedi",
"dimanche",
] as const;
const SLOT_HEIGHT = 20 as const; // Each 15min has a height of 20px in the timetable
const SLOT_WIDTH = 250 as const; // Each weekday ha a width of 400px in the timetable
const MINUTES_PER_SLOT = 15 as const;
interface TimetableSlot {
courseType: string;
room: string;
startHour: string;
endHour: string;
startSlot: number;
endSlot: number;
ueCode: string;
weekGroup?: string;
weekday: WeekDay;
}
function parseSlots(s: string): TimetableSlot[] {
return s
.split("\n")
.filter((s: string) => s.length > 0)
.map((row: string) => {
const parsed = TIMETABLE_ROW_RE.exec(row);
if (!parsed) {
throw new Error(`Couldn't parse row ${row}`);
}
const [startHour, startMin] = parsed.groups.startHour
.split(":")
.map((i) => Number.parseInt(i));
const [endHour, endMin] = parsed.groups.endHour
.split(":")
.map((i) => Number.parseInt(i));
return {
...parsed.groups,
startSlot: Math.floor((startHour * 60 + startMin) / MINUTES_PER_SLOT),
endSlot: Math.floor((endHour * 60 + endMin) / MINUTES_PER_SLOT),
} as unknown as TimetableSlot;
});
}
document.addEventListener("alpine:init", () => {
Alpine.data("timetableGenerator", () => ({
content: DEFAULT_TIMETABLE,
error: "",
displayedWeekdays: [] as WeekDay[],
courses: [] as TimetableSlot[],
startSlot: 0,
endSlot: 0,
table: {
height: 0,
width: 0,
},
colors: {} as Record<string, string>,
colorPalette: [
"#27ae60",
"#2980b9",
"#c0392b",
"#7f8c8d",
"#f1c40f",
"#1abc9c",
"#95a5a6",
"#26C6DA",
"#c2185b",
"#e64a19",
"#1b5e20",
],
generate() {
try {
this.courses = parseSlots(this.content);
} catch {
this.error = gettext(
"Wrong timetable format. Make sure you copied if from your student folder.",
);
return;
}
// color each UE
let colorIndex = 0;
for (const slot of this.courses) {
if (!this.colors[slot.ueCode]) {
this.colors[slot.ueCode] =
this.colorPalette[colorIndex % this.colorPalette.length];
colorIndex++;
}
}
this.displayedWeekdays = WEEKDAYS.filter((day) =>
this.courses.some((slot: TimetableSlot) => slot.weekday === day),
);
this.startSlot = this.courses.reduce(
(acc: number, curr: TimetableSlot) => Math.min(acc, curr.startSlot),
25 * 4,
);
this.endSlot = this.courses.reduce(
(acc: number, curr: TimetableSlot) => Math.max(acc, curr.endSlot),
1,
);
this.table.height = SLOT_HEIGHT * (this.endSlot - this.startSlot);
this.table.width = SLOT_WIDTH * this.displayedWeekdays.length;
},
getStyle(slot: TimetableSlot) {
const hasWeekGroup = slot.weekGroup !== undefined;
const width = hasWeekGroup ? SLOT_WIDTH / 2 : SLOT_WIDTH;
const leftOffset = slot.weekGroup === "B" ? SLOT_WIDTH / 2 : 0;
return {
height: `${(slot.endSlot - slot.startSlot) * SLOT_HEIGHT}px`,
width: `${width}px`,
top: `${(slot.startSlot - this.startSlot) * SLOT_HEIGHT}px`,
left: `${this.displayedWeekdays.indexOf(slot.weekday) * SLOT_WIDTH + leftOffset}px`,
backgroundColor: this.colors[slot.ueCode],
};
},
getHours(): [string, object][] {
let hour: number = Number.parseInt(
this.courses
.map((c: TimetableSlot) => c.startHour)
.reduce((res: string, hour: string) => (hour < res ? hour : res), "24:00")
.split(":")[0],
);
const res: [string, object][] = [];
for (let i = 0; i <= this.endSlot - this.startSlot; i += 60 / MINUTES_PER_SLOT) {
res.push([`${hour}:00`, { top: `${i * SLOT_HEIGHT}px` }]);
hour += 1;
}
return res;
},
getWidth() {
return this.displayedWeekdays.length * SLOT_WIDTH + 20;
},
async savePng() {
const elem = document.getElementById("timetable");
const img = (await html2canvas(elem)).toDataURL();
const downloadLink = document.createElement("a");
downloadLink.href = img;
downloadLink.download = "edt.png";
document.body.appendChild(downloadLink);
downloadLink.click();
downloadLink.remove();
},
}));
});

View File

@@ -0,0 +1,67 @@
@import "core/static/core/colors";
#timetable {
--hour-side-width: 60px;
display: block;
margin: 2em auto;
.header {
background-color: $white-color;
font-weight: bold;
box-shadow: none;
width: calc(100% - var(--hour-side-width) - 10px);
margin-left: var(--hour-side-width);
padding-left: 0;
display: flex;
flex-direction: row;
gap: 0;
span {
flex: 1;
text-align: center;
}
}
.content {
position: relative;
}
.hours {
position: absolute;
width: 40px;
left: 0;
top: -.5em;
.hour {
position: absolute;
.hour-bar {
content: "";
position: absolute;
height: 1px;
background: lightgray;
top: 50%;
left: 100%;
margin-left: 10px;
}
}
}
.courses {
position: absolute;
text-align: center;
top: 0;
left: var(--hour-side-width);
.slot {
background-color: cadetblue;
position: absolute;
display: flex;
flex-direction: column;
justify-content: center;
.course-type {
position: absolute;
top: 0;
right: 0;
padding: 10px;
}
}
}
}

View File

@@ -0,0 +1,68 @@
{% extends 'core/base.jinja' %}
{%- block additional_css -%}
<link rel="stylesheet" href="{{ static('timetable/css/generator.scss') }}">
{%- endblock -%}
{%- block additional_js -%}
<script type="module" src="{{ static('bundled/timetable/generator-index.ts') }}"></script>
{%- endblock -%}
{% block title %}
{% trans %}Timetable generator{% endtrans %}
{% endblock %}
{% block content %}
<div x-data="timetableGenerator">
<form @submit.prevent="generate()">
<h1>Générateur d'emploi du temps</h1>
<div class="alert alert-red" x-show="!!error" x-cloak>
<span class="alert-main" x-text="error"></span>
</div>
<div class="form-group">
<label for="timetable-input">Colle ton emploi du temps (sans l'entête)</label>
<textarea id="timetable-input" cols="30" rows="15" x-model="content"></textarea>
</div>
<input type="submit" class="btn btn-blue" value="{% trans %}Generate{% endtrans %}">
</form>
<div
id="timetable"
x-show="table.height > 0 && table.width > 0"
:style="{width: `${table.width+80}px`, height: `${table.height+40}px`}"
>
<div class="header">
<template x-for="weekday in displayedWeekdays">
<span x-text="weekday"></span>
</template>
</div>
<div class="content">
<div class="hours" :height="(endSlot - endSlot%4) - (startSlot - startSlot%4)">
<template x-for="[hour, style] in getHours()">
<div class="hour" :style="style">
<div x-text="hour"></div>
<div class="hour-bar" :style="{width: `${getWidth()}px`}"></div>
</div>
</template>
</div>
<div class="courses">
<template x-for="course in courses">
<div class="slot" :style="getStyle(course)">
<span class="course-type" x-text="course.courseType"></span>
<span x-text="course.ueCode"></span>
<span x-text="`${course.startHour} - ${course.endHour}`"></span>
<span x-text="(course.weekGroup ? `\nGroupe ${course.weekGroup}` : '')"></span>
<span x-text="course.room"></span>
</div>
</template>
</div>
</div>
</div>
<button
class="margin-bottom btn btn-blue"
@click="savePng"
x-show="table.height > 0 && table.width > 0"
>
{% trans %}Save to PNG{% endtrans %}
</button>
</div>
{% endblock content %}

1
timetable/tests.py Normal file
View File

@@ -0,0 +1 @@
# Create your tests here.

5
timetable/urls.py Normal file
View File

@@ -0,0 +1,5 @@
from django.urls import path
from timetable.views import GeneratorView
urlpatterns = [path("", GeneratorView.as_view(), name="generator")]

8
timetable/views.py Normal file
View File

@@ -0,0 +1,8 @@
# Create your views here.
from django.views.generic import TemplateView
from core.auth.mixins import FormerSubscriberMixin
class GeneratorView(FormerSubscriberMixin, TemplateView):
template_name = "timetable/generator.jinja"