mirror of
https://github.com/ae-utbm/sith.git
synced 2026-10-10 04:40:28 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c6594b3cd |
No files matched your search
@@ -1,22 +0,0 @@
|
|||||||
# Generated by Django 5.2.17 on 2026-09-24 14:49
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [("core", "0051_user_cgu_approved_at")]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="preferences",
|
|
||||||
name="show_my_timetable",
|
|
||||||
field=models.BooleanField(
|
|
||||||
default=True,
|
|
||||||
help_text=(
|
|
||||||
"Allow subscribers to see your timetable and "
|
|
||||||
"to compare it to theirs (if you have filled it)"
|
|
||||||
),
|
|
||||||
verbose_name="show your timetable to others",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -799,14 +799,6 @@ class Preferences(models.Model):
|
|||||||
),
|
),
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
show_my_timetable = models.BooleanField(
|
|
||||||
_("show your timetable to others"),
|
|
||||||
help_text=_(
|
|
||||||
"Allow subscribers to see your timetable and "
|
|
||||||
"to compare it to theirs (if you have filled it)"
|
|
||||||
),
|
|
||||||
default=True,
|
|
||||||
)
|
|
||||||
notify_on_click = models.BooleanField(
|
notify_on_click = models.BooleanField(
|
||||||
_("get a notification for every click"), default=False
|
_("get a notification for every click"), default=False
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,13 +29,5 @@
|
|||||||
</span>
|
</span>
|
||||||
{{ form.show_my_stats.errors }}
|
{{ form.show_my_stats.errors }}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset class="form-group">
|
|
||||||
{{ form.show_my_timetable }}
|
|
||||||
{{ form.show_my_timetable.label_tag() }}
|
|
||||||
<span class="helptext">
|
|
||||||
{{ form.show_my_timetable.help_text }}
|
|
||||||
</span>
|
|
||||||
{{ form.show_my_timetable.errors }}
|
|
||||||
</fieldset>
|
|
||||||
<input type="submit" class="btn btn-blue" value="{% trans %}Save{% endtrans %}">
|
<input type="submit" class="btn btn-blue" value="{% trans %}Save{% endtrans %}">
|
||||||
</form>
|
</form>
|
||||||
@@ -84,7 +84,6 @@ from core.views import (
|
|||||||
notification,
|
notification,
|
||||||
send_file,
|
send_file,
|
||||||
)
|
)
|
||||||
from timetable.views import UserTimetableView
|
|
||||||
|
|
||||||
register_converter(FourDigitYearConverter, "yyyy")
|
register_converter(FourDigitYearConverter, "yyyy")
|
||||||
register_converter(TwoDigitMonthConverter, "mm")
|
register_converter(TwoDigitMonthConverter, "mm")
|
||||||
@@ -185,11 +184,6 @@ urlpatterns = [
|
|||||||
name="user_account_detail",
|
name="user_account_detail",
|
||||||
),
|
),
|
||||||
path("user/<int:user_id>/stats/", UserStatsView.as_view(), name="user_stats"),
|
path("user/<int:user_id>/stats/", UserStatsView.as_view(), name="user_stats"),
|
||||||
path(
|
|
||||||
"user/<int:user_id>/edt/",
|
|
||||||
UserTimetableView.as_view(),
|
|
||||||
name="user_timetable",
|
|
||||||
),
|
|
||||||
path(
|
path(
|
||||||
"user/<int:user_id>/gift/create/",
|
"user/<int:user_id>/gift/create/",
|
||||||
GiftCreateView.as_view(),
|
GiftCreateView.as_view(),
|
||||||
|
|||||||
+2
-10
@@ -336,14 +336,10 @@ class UserVisibilityForm(forms.ModelForm):
|
|||||||
|
|
||||||
__preferences_fields = forms.fields_for_model(
|
__preferences_fields = forms.fields_for_model(
|
||||||
Preferences,
|
Preferences,
|
||||||
["show_my_stats", "show_my_timetable"],
|
["show_my_stats"],
|
||||||
widgets={
|
widgets={"show_my_stats": forms.CheckboxInput(attrs={"class": "switch"})},
|
||||||
"show_my_stats": forms.CheckboxInput(attrs={"class": "switch"}),
|
|
||||||
"show_my_timetable": forms.CheckboxInput(attrs={"class": "switch"}),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
show_my_stats = __preferences_fields["show_my_stats"]
|
show_my_stats = __preferences_fields["show_my_stats"]
|
||||||
show_my_timetable = __preferences_fields["show_my_timetable"]
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, *args, initial: dict | None = None, instance: User | None = None, **kwargs
|
self, *args, initial: dict | None = None, instance: User | None = None, **kwargs
|
||||||
@@ -351,16 +347,12 @@ class UserVisibilityForm(forms.ModelForm):
|
|||||||
if instance:
|
if instance:
|
||||||
initial = initial or {}
|
initial = initial or {}
|
||||||
initial["show_my_stats"] = instance.preferences.show_my_stats
|
initial["show_my_stats"] = instance.preferences.show_my_stats
|
||||||
initial["show_my_timetable"] = instance.preferences.show_my_timetable
|
|
||||||
super().__init__(*args, initial=initial, instance=instance, **kwargs)
|
super().__init__(*args, initial=initial, instance=instance, **kwargs)
|
||||||
|
|
||||||
def save(self, commit=True) -> User: # noqa: FBT002
|
def save(self, commit=True) -> User: # noqa: FBT002
|
||||||
instance = super().save(commit=commit)
|
instance = super().save(commit=commit)
|
||||||
if commit:
|
if commit:
|
||||||
instance.preferences.show_my_stats = self.cleaned_data["show_my_stats"]
|
instance.preferences.show_my_stats = self.cleaned_data["show_my_stats"]
|
||||||
instance.preferences.show_my_timetable = self.cleaned_data[
|
|
||||||
"show_my_timetable"
|
|
||||||
]
|
|
||||||
instance.preferences.save()
|
instance.preferences.save()
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
|||||||
+2
-16
@@ -1,14 +1,6 @@
|
|||||||
import copy
|
import copy
|
||||||
import inspect
|
import inspect
|
||||||
from typing import (
|
from typing import TYPE_CHECKING, Any, ClassVar, LiteralString, Protocol, Unpack
|
||||||
TYPE_CHECKING,
|
|
||||||
Any,
|
|
||||||
ClassVar,
|
|
||||||
LiteralString,
|
|
||||||
Protocol,
|
|
||||||
TypedDict,
|
|
||||||
Unpack,
|
|
||||||
)
|
|
||||||
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
@@ -20,17 +12,11 @@ if TYPE_CHECKING:
|
|||||||
from django.utils.safestring import SafeString
|
from django.utils.safestring import SafeString
|
||||||
|
|
||||||
|
|
||||||
class TabItem(TypedDict):
|
|
||||||
url: str
|
|
||||||
slug: str
|
|
||||||
name: str
|
|
||||||
|
|
||||||
|
|
||||||
class TabedViewMixin(View):
|
class TabedViewMixin(View):
|
||||||
"""Basic functions for displaying tabs in the template."""
|
"""Basic functions for displaying tabs in the template."""
|
||||||
|
|
||||||
current_tab: ClassVar[str | None] = None
|
current_tab: ClassVar[str | None] = None
|
||||||
list_of_tabs: ClassVar[list[TabItem] | None] = None
|
list_of_tabs: ClassVar[list | None] = None
|
||||||
tabs_title: ClassVar[str | None] = None
|
tabs_title: ClassVar[str | None] = None
|
||||||
|
|
||||||
def get_tabs_title(self):
|
def get_tabs_title(self):
|
||||||
|
|||||||
@@ -83,8 +83,17 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div class="alert alert-yellow">
|
||||||
|
<div class="alert-main">
|
||||||
|
{%- trans trimmed -%}
|
||||||
|
You can see product details.
|
||||||
|
However, you cannot edit it, thus you won't be able
|
||||||
|
to submit this form.
|
||||||
|
{%- endtrans -%}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% if object %}
|
{% if object %}
|
||||||
<h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2>
|
<h2>{{ object }}</h2>
|
||||||
<p><i>{% trans %}Creation date{% endtrans %} : {{ object.created_at|date }}</i></p>
|
<p><i>{% trans %}Creation date{% endtrans %} : {{ object.created_at|date }}</i></p>
|
||||||
<p><i>{% trans %}Last update{% endtrans %} : {{ object.updated_at|date }}</i></p>
|
<p><i>{% trans %}Last update{% endtrans %} : {{ object.updated_at|date }}</i></p>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -127,19 +136,17 @@
|
|||||||
{{ form.price_formset.management_form }}
|
{{ form.price_formset.management_form }}
|
||||||
<div x-ref="formContainer">
|
<div x-ref="formContainer">
|
||||||
{%- for form in form.price_formset.forms -%}
|
{%- for form in form.price_formset.forms -%}
|
||||||
<div>
|
<div>{{ price_form(form) }}</div>
|
||||||
{{ price_form(form) }}
|
|
||||||
</div>
|
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
</div>
|
</div>
|
||||||
<template x-ref="formTemplate">
|
<template x-ref="formTemplate">
|
||||||
<div>
|
<div>{{ price_form(form.price_formset.empty_form) }}</div>
|
||||||
{{ price_form(form.price_formset.empty_form) }}
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<button class="btn btn-grey" @click.prevent="addForm()">
|
{% if user.has_perm("counter.change_product") %}
|
||||||
<i class="fa fa-plus"></i> {% trans %}Add a price{% endtrans %}
|
<button class="btn btn-grey" @click.prevent="addForm()">
|
||||||
</button>
|
<i class="fa fa-plus"></i> {% trans %}Add a price{% endtrans %}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
@@ -165,11 +172,16 @@
|
|||||||
<template x-ref="formTemplate">
|
<template x-ref="formTemplate">
|
||||||
{{ action_form(form.action_formset.empty_form) }}
|
{{ action_form(form.action_formset.empty_form) }}
|
||||||
</template>
|
</template>
|
||||||
<button @click.prevent="addForm()" class="btn btn-grey">
|
{% if user.has_perm("counter.change_product") %}
|
||||||
<i class="fa fa-plus"></i>{% trans %}Add action{% endtrans %}
|
<button @click.prevent="addForm()" class="btn btn-grey">
|
||||||
</button>
|
<i class="fa fa-plus"></i>{% trans %}Add action{% endtrans %}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="row gap margin-bottom">{{ form.archived.as_field_group() }}</div>
|
<div class="row gap margin-bottom">{{ form.archived.as_field_group() }}</div>
|
||||||
<p><input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
|
||||||
|
{% if user.has_perm("counter.change_product") %}
|
||||||
|
<p><input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
||||||
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -80,9 +80,11 @@
|
|||||||
<h3 class="margin-bottom">{% trans %}Product list{% endtrans %}</h3>
|
<h3 class="margin-bottom">{% trans %}Product list{% endtrans %}</h3>
|
||||||
|
|
||||||
<div class="row margin-bottom">
|
<div class="row margin-bottom">
|
||||||
<a href="{{ url('counter:new_product') }}" class="btn btn-blue">
|
{% if user.has_perm("counter.add_product") %}
|
||||||
{% trans %}New product{% endtrans %} <i class="fa fa-plus"></i>
|
<a href="{{ url('counter:new_product') }}" class="btn btn-blue">
|
||||||
</a>
|
{% trans %}New product{% endtrans %} <i class="fa fa-plus"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
<button
|
<button
|
||||||
class="btn btn-blue"
|
class="btn btn-blue"
|
||||||
@click="downloadCsv()"
|
@click="downloadCsv()"
|
||||||
|
|||||||
@@ -13,12 +13,12 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p>
|
{% if user.has_perm("counter.add_producttype") %}
|
||||||
<a href="{{ url('counter:new_product_type') }}" class="btn btn-blue">
|
<a href="{{ url('counter:new_product_type') }}" class="btn btn-blue margin-bottom">
|
||||||
{% trans %}New product type{% endtrans %}
|
{% trans %}New product type{% endtrans %}
|
||||||
<i class="fa fa-plus"></i>
|
<i class="fa fa-plus"></i>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
{% endif %}
|
||||||
{% if product_types %}
|
{% if product_types %}
|
||||||
<aside>
|
<aside>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
+34
-21
@@ -48,7 +48,7 @@ from counter.models import (
|
|||||||
Selling,
|
Selling,
|
||||||
)
|
)
|
||||||
from counter.utils import is_logged_in_counter
|
from counter.utils import is_logged_in_counter
|
||||||
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
|
from counter.views.mixins import CounterAdminTabsMixin
|
||||||
|
|
||||||
|
|
||||||
class CounterListView(CounterAdminTabsMixin, CanViewMixin, ListView):
|
class CounterListView(CounterAdminTabsMixin, CanViewMixin, ListView):
|
||||||
@@ -84,17 +84,18 @@ class CounterEditView(
|
|||||||
return reverse_lazy("counter:admin", kwargs={"counter_id": self.object.id})
|
return reverse_lazy("counter:admin", kwargs={"counter_id": self.object.id})
|
||||||
|
|
||||||
|
|
||||||
class CounterEditPropView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
class CounterEditPropView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
|
||||||
"""Edit a counter's main informations (for the counter's admin)."""
|
"""Edit a counter's main infos."""
|
||||||
|
|
||||||
model = Counter
|
model = Counter
|
||||||
form_class = modelform_factory(Counter, fields=["name", "club", "type"])
|
form_class = modelform_factory(Counter, fields=["name", "club", "type"])
|
||||||
pk_url_kwarg = "counter_id"
|
pk_url_kwarg = "counter_id"
|
||||||
template_name = "core/edit.jinja"
|
template_name = "core/edit.jinja"
|
||||||
current_tab = "counters"
|
current_tab = "counters"
|
||||||
|
permission_required = "counter.change_counter"
|
||||||
|
|
||||||
|
|
||||||
class CounterCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
|
class CounterCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView):
|
||||||
"""Create a counter (for the admins)."""
|
"""Create a counter (for the admins)."""
|
||||||
|
|
||||||
model = Counter
|
model = Counter
|
||||||
@@ -105,9 +106,10 @@ class CounterCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
|
|||||||
)
|
)
|
||||||
template_name = "core/create.jinja"
|
template_name = "core/create.jinja"
|
||||||
current_tab = "counters"
|
current_tab = "counters"
|
||||||
|
permission_required = "counter.add_counter"
|
||||||
|
|
||||||
|
|
||||||
class CounterDeleteView(CounterAdminTabsMixin, CounterAdminMixin, DeleteView):
|
class CounterDeleteView(CounterAdminTabsMixin, PermissionRequiredMixin, DeleteView):
|
||||||
"""Delete a counter (for the admins)."""
|
"""Delete a counter (for the admins)."""
|
||||||
|
|
||||||
model = Counter
|
model = Counter
|
||||||
@@ -115,30 +117,33 @@ class CounterDeleteView(CounterAdminTabsMixin, CounterAdminMixin, DeleteView):
|
|||||||
template_name = "core/delete_confirm.jinja"
|
template_name = "core/delete_confirm.jinja"
|
||||||
success_url = reverse_lazy("counter:admin_list")
|
success_url = reverse_lazy("counter:admin_list")
|
||||||
current_tab = "counters"
|
current_tab = "counters"
|
||||||
|
permission_required = "counter.delete_counter"
|
||||||
|
|
||||||
|
|
||||||
# Product management
|
# Product management
|
||||||
|
|
||||||
|
|
||||||
class ProductTypeListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
class ProductTypeListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
|
||||||
"""A list view for the admins."""
|
"""A list view for the admins."""
|
||||||
|
|
||||||
model = ProductType
|
model = ProductType
|
||||||
template_name = "counter/product_type_list.jinja"
|
template_name = "counter/product_type_list.jinja"
|
||||||
current_tab = "product_types"
|
current_tab = "product_types"
|
||||||
context_object_name = "product_types"
|
context_object_name = "product_types"
|
||||||
|
permission_required = "counter.view_producttype"
|
||||||
|
|
||||||
|
|
||||||
class ProductTypeCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
|
class ProductTypeCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView):
|
||||||
"""A create view for the admins."""
|
"""A create view for the admins."""
|
||||||
|
|
||||||
model = ProductType
|
model = ProductType
|
||||||
fields = ["name", "description", "comment", "icon"]
|
fields = ["name", "description", "comment", "icon"]
|
||||||
template_name = "core/create.jinja"
|
template_name = "core/create.jinja"
|
||||||
current_tab = "products"
|
current_tab = "products"
|
||||||
|
permission_required = "counter.add_producttype"
|
||||||
|
|
||||||
|
|
||||||
class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
class ProductTypeEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
|
||||||
"""An edit view for the admins."""
|
"""An edit view for the admins."""
|
||||||
|
|
||||||
model = ProductType
|
model = ProductType
|
||||||
@@ -146,23 +151,26 @@ class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
|||||||
fields = ["name", "description", "comment", "icon"]
|
fields = ["name", "description", "comment", "icon"]
|
||||||
pk_url_kwarg = "type_id"
|
pk_url_kwarg = "type_id"
|
||||||
current_tab = "products"
|
current_tab = "products"
|
||||||
|
permission_required = "counter.change_producttype"
|
||||||
|
|
||||||
|
|
||||||
class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
|
class ProductListView(CounterAdminTabsMixin, PermissionRequiredMixin, TemplateView):
|
||||||
current_tab = "products"
|
current_tab = "products"
|
||||||
template_name = "counter/product_list.jinja"
|
template_name = "counter/product_list.jinja"
|
||||||
|
permission_required = "counter.view_product"
|
||||||
|
|
||||||
|
|
||||||
class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
|
class ProductCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView):
|
||||||
"""A create view for the admins."""
|
"""A create view for the admins."""
|
||||||
|
|
||||||
model = Product
|
model = Product
|
||||||
form_class = ProductForm
|
form_class = ProductForm
|
||||||
template_name = "counter/product_form.jinja"
|
template_name = "counter/product_form.jinja"
|
||||||
current_tab = "products"
|
current_tab = "products"
|
||||||
|
permission_required = "counter.add_product"
|
||||||
|
|
||||||
|
|
||||||
class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
class ProductEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
|
||||||
"""An edit view for the admins."""
|
"""An edit view for the admins."""
|
||||||
|
|
||||||
model = Product
|
model = Product
|
||||||
@@ -170,6 +178,13 @@ class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
|||||||
pk_url_kwarg = "product_id"
|
pk_url_kwarg = "product_id"
|
||||||
template_name = "counter/product_form.jinja"
|
template_name = "counter/product_form.jinja"
|
||||||
current_tab = "products"
|
current_tab = "products"
|
||||||
|
permission_required = "counter.change_product"
|
||||||
|
|
||||||
|
def has_permission(self):
|
||||||
|
return self.request.user.has_perm("counter.change_product") or (
|
||||||
|
self.request.method == "GET"
|
||||||
|
and self.request.user.has_perm("counter.view_product")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ProductFormulaListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
|
class ProductFormulaListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
|
||||||
@@ -299,7 +314,6 @@ class RefillingDeleteView(DeleteView):
|
|||||||
template_name = "core/delete_confirm.jinja"
|
template_name = "core/delete_confirm.jinja"
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
"""We have here a very particular right handling, we can't inherit from CanEditPropMixin."""
|
|
||||||
self.object = self.get_object()
|
self.object = self.get_object()
|
||||||
if timezone.now() - self.object.date <= timedelta(
|
if timezone.now() - self.object.date <= timedelta(
|
||||||
minutes=settings.SITH_LAST_OPERATIONS_LIMIT
|
minutes=settings.SITH_LAST_OPERATIONS_LIMIT
|
||||||
@@ -324,7 +338,6 @@ class SellingDeleteView(DeleteView):
|
|||||||
template_name = "core/delete_confirm.jinja"
|
template_name = "core/delete_confirm.jinja"
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
"""We have here a very particular right handling, we can't inherit from CanEditPropMixin."""
|
|
||||||
self.object = self.get_object()
|
self.object = self.get_object()
|
||||||
if timezone.now() - self.object.date <= timedelta(
|
if timezone.now() - self.object.date <= timedelta(
|
||||||
minutes=settings.SITH_LAST_OPERATIONS_LIMIT
|
minutes=settings.SITH_LAST_OPERATIONS_LIMIT
|
||||||
@@ -376,23 +389,23 @@ class CounterStatView(PermissionRequiredMixin, DetailView):
|
|||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
class CounterRefillingListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
class CounterRefillingListView(
|
||||||
|
CounterAdminTabsMixin, PermissionRequiredMixin, ListView
|
||||||
|
):
|
||||||
"""List of refillings on a counter."""
|
"""List of refillings on a counter."""
|
||||||
|
|
||||||
model = Refilling
|
model = Refilling
|
||||||
template_name = "counter/refilling_list.jinja"
|
template_name = "counter/refilling_list.jinja"
|
||||||
current_tab = "counters"
|
current_tab = "counters"
|
||||||
paginate_by = 30
|
paginate_by = 30
|
||||||
|
permission_required = "counter.view_refilling"
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def get_queryset(self):
|
||||||
self.counter = get_object_or_404(Counter, pk=kwargs["counter_id"])
|
self.counter = get_object_or_404(Counter, pk=self.kwargs["counter_id"])
|
||||||
self.queryset = Refilling.objects.filter(counter__id=self.counter.id)
|
return Refilling.objects.filter(counter=self.counter).order_by("-date")
|
||||||
return super().dispatch(request, *args, **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) | {"counter": self.counter}
|
||||||
kwargs["counter"] = self.counter
|
|
||||||
return kwargs
|
|
||||||
|
|
||||||
|
|
||||||
class RefoundAccountView(UserPassesTestMixin, FormView):
|
class RefoundAccountView(UserPassesTestMixin, FormView):
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||||
from django.http import Http404, HttpResponse
|
from django.http import Http404, HttpResponse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import DetailView, ListView
|
from django.views.generic import DetailView, ListView
|
||||||
@@ -21,28 +21,30 @@ from django.views.generic.edit import CreateView, UpdateView
|
|||||||
from core.auth.mixins import CanViewMixin
|
from core.auth.mixins import CanViewMixin
|
||||||
from counter.forms import EticketForm
|
from counter.forms import EticketForm
|
||||||
from counter.models import Eticket, Selling
|
from counter.models import Eticket, Selling
|
||||||
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
|
from counter.views.mixins import CounterAdminTabsMixin
|
||||||
|
|
||||||
|
|
||||||
class EticketListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
class EticketListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
|
||||||
"""A list view for the admins."""
|
"""A list view for the admins."""
|
||||||
|
|
||||||
model = Eticket
|
model = Eticket
|
||||||
template_name = "counter/eticket_list.jinja"
|
template_name = "counter/eticket_list.jinja"
|
||||||
ordering = ["id"]
|
ordering = ["id"]
|
||||||
current_tab = "etickets"
|
current_tab = "etickets"
|
||||||
|
permission_required = "counter.view_eticket"
|
||||||
|
|
||||||
|
|
||||||
class EticketCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
|
class EticketCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView):
|
||||||
"""Create an eticket."""
|
"""Create an eticket."""
|
||||||
|
|
||||||
model = Eticket
|
model = Eticket
|
||||||
template_name = "core/create.jinja"
|
template_name = "core/create.jinja"
|
||||||
form_class = EticketForm
|
form_class = EticketForm
|
||||||
current_tab = "etickets"
|
current_tab = "etickets"
|
||||||
|
permission_required = "counter.add_eticket"
|
||||||
|
|
||||||
|
|
||||||
class EticketEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
class EticketEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
|
||||||
"""Edit an eticket."""
|
"""Edit an eticket."""
|
||||||
|
|
||||||
model = Eticket
|
model = Eticket
|
||||||
@@ -50,6 +52,7 @@ class EticketEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
|||||||
form_class = EticketForm
|
form_class = EticketForm
|
||||||
pk_url_kwarg = "eticket_id"
|
pk_url_kwarg = "eticket_id"
|
||||||
current_tab = "etickets"
|
current_tab = "etickets"
|
||||||
|
permission_required = "counter.change_eticket"
|
||||||
|
|
||||||
|
|
||||||
class EticketPDFView(CanViewMixin, DetailView):
|
class EticketPDFView(CanViewMixin, DetailView):
|
||||||
|
|||||||
+67
-67
@@ -13,38 +13,13 @@
|
|||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.exceptions import PermissionDenied
|
|
||||||
from django.urls import reverse, reverse_lazy
|
from django.urls import reverse, reverse_lazy
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic.base import View
|
|
||||||
|
|
||||||
from core.views.mixins import TabedViewMixin
|
from core.views.mixins import TabedViewMixin
|
||||||
from counter.utils import is_logged_in_counter
|
from counter.utils import is_logged_in_counter
|
||||||
|
|
||||||
|
|
||||||
class CounterAdminMixin(View):
|
|
||||||
"""Protect counter admin section."""
|
|
||||||
|
|
||||||
edit_group = [settings.SITH_GROUP_COUNTER_ADMIN_ID]
|
|
||||||
edit_club = []
|
|
||||||
|
|
||||||
def _test_group(self, user):
|
|
||||||
return any(user.is_in_group(pk=grp_id) for grp_id in self.edit_group)
|
|
||||||
|
|
||||||
def _test_club(self, user):
|
|
||||||
return any(c.can_be_edited_by(user) for c in self.edit_club)
|
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
|
||||||
if not (
|
|
||||||
request.user.is_root
|
|
||||||
or self._test_group(request.user)
|
|
||||||
or self._test_club(request.user)
|
|
||||||
):
|
|
||||||
raise PermissionDenied
|
|
||||||
return super().dispatch(request, *args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class CounterTabsMixin(TabedViewMixin):
|
class CounterTabsMixin(TabedViewMixin):
|
||||||
def get_tabs_title(self):
|
def get_tabs_title(self):
|
||||||
return self.object
|
return self.object
|
||||||
@@ -89,45 +64,70 @@ class CounterTabsMixin(TabedViewMixin):
|
|||||||
|
|
||||||
class CounterAdminTabsMixin(TabedViewMixin):
|
class CounterAdminTabsMixin(TabedViewMixin):
|
||||||
tabs_title = _("Counter administration")
|
tabs_title = _("Counter administration")
|
||||||
list_of_tabs = [
|
|
||||||
{
|
def get_list_of_tabs(self):
|
||||||
"url": reverse_lazy("counter:admin_list"),
|
user = self.request.user
|
||||||
"slug": "counters",
|
res = [
|
||||||
"name": _("Counters"),
|
{
|
||||||
},
|
"url": reverse_lazy("counter:admin_list"),
|
||||||
{
|
"slug": "counters",
|
||||||
"url": reverse_lazy("counter:product_list"),
|
"name": _("Counters"),
|
||||||
"slug": "products",
|
}
|
||||||
"name": _("Products"),
|
]
|
||||||
},
|
if user.has_perm("counter.view_product"):
|
||||||
{
|
res.append(
|
||||||
"url": reverse_lazy("counter:product_formula_list"),
|
{
|
||||||
"slug": "formulas",
|
"url": reverse_lazy("counter:product_list"),
|
||||||
"name": _("Formulas"),
|
"slug": "products",
|
||||||
},
|
"name": _("Products"),
|
||||||
{
|
}
|
||||||
"url": reverse_lazy("counter:product_type_list"),
|
)
|
||||||
"slug": "product_types",
|
if user.has_perm("counter.view_productformula"):
|
||||||
"name": _("Product types"),
|
res.append(
|
||||||
},
|
{
|
||||||
{
|
"url": reverse_lazy("counter:product_formula_list"),
|
||||||
"url": reverse_lazy("counter:returnable_list"),
|
"slug": "formulas",
|
||||||
"slug": "returnable_products",
|
"name": _("Formulas"),
|
||||||
"name": _("Returnable products"),
|
}
|
||||||
},
|
)
|
||||||
{
|
if user.has_perm("counter.view_producttype"):
|
||||||
"url": reverse_lazy("counter:cash_summary_list"),
|
res.append(
|
||||||
"slug": "cash_summary",
|
{
|
||||||
"name": _("Cash register summaries"),
|
"url": reverse_lazy("counter:product_type_list"),
|
||||||
},
|
"slug": "product_types",
|
||||||
{
|
"name": _("Product types"),
|
||||||
"url": reverse_lazy("counter:invoices_call"),
|
}
|
||||||
"slug": "invoices_call",
|
)
|
||||||
"name": _("Invoices call"),
|
if user.has_perm("counter.view_returnableproduct"):
|
||||||
},
|
res.append(
|
||||||
{
|
{
|
||||||
"url": reverse_lazy("counter:eticket_list"),
|
"url": reverse_lazy("counter:returnable_list"),
|
||||||
"slug": "etickets",
|
"slug": "returnable_products",
|
||||||
"name": _("Etickets"),
|
"name": _("Returnable products"),
|
||||||
},
|
}
|
||||||
]
|
)
|
||||||
|
if user.has_perm("counter.view_cashregistersummary"):
|
||||||
|
res.append(
|
||||||
|
{
|
||||||
|
"url": reverse_lazy("counter:cash_summary_list"),
|
||||||
|
"slug": "cash_summary",
|
||||||
|
"name": _("Cash register summaries"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if user.has_perm("counter.view_invoicecall"):
|
||||||
|
res.append(
|
||||||
|
{
|
||||||
|
"url": reverse_lazy("counter:invoices_call"),
|
||||||
|
"slug": "invoices_call",
|
||||||
|
"name": _("Invoices call"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if user.has_perm("counter.view_eticket"):
|
||||||
|
res.append(
|
||||||
|
{
|
||||||
|
"url": reverse_lazy("counter:eticket_list"),
|
||||||
|
"slug": "etickets",
|
||||||
|
"name": _("Etickets"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return res
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-10-02 12:56+0200\n"
|
"POT-Creation-Date: 2026-10-09 21:44+0200\n"
|
||||||
"PO-Revision-Date: 2016-07-18\n"
|
"PO-Revision-Date: 2016-07-18\n"
|
||||||
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
|
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
|
||||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||||
@@ -236,7 +236,7 @@ msgstr "vous devez spécifier au moins un utilisateur ou une adresse email"
|
|||||||
msgid "Begin date"
|
msgid "Begin date"
|
||||||
msgstr "Date de début"
|
msgstr "Date de début"
|
||||||
|
|
||||||
#: club/forms.py com/forms.py counter/forms.py subscription/forms.py
|
#: club/forms.py com/forms.py counter/forms.py
|
||||||
msgid "End date"
|
msgid "End date"
|
||||||
msgstr "Date de fin"
|
msgstr "Date de fin"
|
||||||
|
|
||||||
@@ -1093,7 +1093,7 @@ msgstr "Prix d'achat"
|
|||||||
msgid "Format: 16:9 | Resolution: 1920x1080"
|
msgid "Format: 16:9 | Resolution: 1920x1080"
|
||||||
msgstr "Format : 16:9 | Résolution : 1920x1080"
|
msgstr "Format : 16:9 | Résolution : 1920x1080"
|
||||||
|
|
||||||
#: com/forms.py subscription/forms.py
|
#: com/forms.py
|
||||||
msgid "Start date"
|
msgid "Start date"
|
||||||
msgstr "Date de début"
|
msgstr "Date de début"
|
||||||
|
|
||||||
@@ -4102,7 +4102,6 @@ msgstr "Nouvelle formule"
|
|||||||
#: counter/templates/counter/fragments/create_student_card.jinja
|
#: counter/templates/counter/fragments/create_student_card.jinja
|
||||||
#: counter/templates/counter/invoices_call.jinja
|
#: counter/templates/counter/invoices_call.jinja
|
||||||
#: sas/templates/sas/picture.jinja
|
#: sas/templates/sas/picture.jinja
|
||||||
#: subscription/templates/subscription/stats.jinja
|
|
||||||
msgid "Go"
|
msgid "Go"
|
||||||
msgstr "Valider"
|
msgstr "Valider"
|
||||||
|
|
||||||
@@ -4237,9 +4236,12 @@ msgid "Remove price"
|
|||||||
msgstr "Retirer le prix"
|
msgstr "Retirer le prix"
|
||||||
|
|
||||||
#: counter/templates/counter/product_form.jinja
|
#: counter/templates/counter/product_form.jinja
|
||||||
#, python-format
|
msgid ""
|
||||||
msgid "Edit product %(name)s"
|
"You can see product details. However, you cannot edit it, thus you won't be "
|
||||||
msgstr "Édition du produit %(name)s"
|
"able to submit this form."
|
||||||
|
msgstr ""
|
||||||
|
"Vous pouvez voir ce produit. Cependant, vous ne pouvez pas l'éditer ; vous "
|
||||||
|
"ne serez donc pas en mesure de soumettre le formulaire."
|
||||||
|
|
||||||
#: counter/templates/counter/product_form.jinja
|
#: counter/templates/counter/product_form.jinja
|
||||||
msgid "Creation date"
|
msgid "Creation date"
|
||||||
@@ -5985,12 +5987,8 @@ msgid "Create another subscription"
|
|||||||
msgstr "Créer une nouvelle cotisation"
|
msgstr "Créer une nouvelle cotisation"
|
||||||
|
|
||||||
#: subscription/templates/subscription/stats.jinja
|
#: subscription/templates/subscription/stats.jinja
|
||||||
msgid "Total subscriptions"
|
msgid "All subscriptions"
|
||||||
msgstr "Cotisations totales"
|
msgstr "Toutes les cotisations"
|
||||||
|
|
||||||
#: subscription/templates/subscription/stats.jinja
|
|
||||||
msgid "Subscriptions by type"
|
|
||||||
msgstr "Cotisations par type"
|
|
||||||
|
|
||||||
#: subscription/templates/subscription/subscription.jinja
|
#: subscription/templates/subscription/subscription.jinja
|
||||||
msgid "Existing member"
|
msgid "Existing member"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type TomSelect from "tom-select";
|
|||||||
import type { TomOption } from "tom-select/src/types";
|
import type { TomOption } from "tom-select/src/types";
|
||||||
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
|
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
|
||||||
import { paginated } from "#core:utils/api";
|
import { paginated } from "#core:utils/api";
|
||||||
import { History } from "#core:utils/history";
|
|
||||||
import {
|
import {
|
||||||
type IdentifiedUserSchema,
|
type IdentifiedUserSchema,
|
||||||
type ModerationRequestSchema,
|
type ModerationRequestSchema,
|
||||||
@@ -228,11 +227,6 @@ document.addEventListener("alpine:init", () => {
|
|||||||
* Error message when a moderation operation fails
|
* Error message when a moderation operation fails
|
||||||
**/
|
**/
|
||||||
moderationError: "",
|
moderationError: "",
|
||||||
/**
|
|
||||||
* Method of pushing new url to the browser history
|
|
||||||
* Used by popstate event and always reset to it's default value when used
|
|
||||||
**/
|
|
||||||
pushstate: History.Push,
|
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
this.pictures = (
|
this.pictures = (
|
||||||
@@ -267,12 +261,10 @@ document.addEventListener("alpine:init", () => {
|
|||||||
if (!event.state || event.state.sasPictureId === undefined) {
|
if (!event.state || event.state.sasPictureId === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.pushstate = History.Replace;
|
|
||||||
this.currentPicture = this.pictures.find(
|
this.currentPicture = this.pictures.find(
|
||||||
(i: PictureSchema) => i.id === Number.parseInt(event.state.sasPictureId, 10),
|
(i: PictureSchema) => i.id === Number.parseInt(event.state.sasPictureId, 10),
|
||||||
) as PictureWithIdentifications;
|
) as PictureWithIdentifications;
|
||||||
});
|
});
|
||||||
this.pushstate = History.Replace; /* Avoid first url push */
|
|
||||||
await this.updatePicture();
|
await this.updatePicture();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -285,17 +277,11 @@ document.addEventListener("alpine:init", () => {
|
|||||||
* the list of identified users are updated.
|
* the list of identified users are updated.
|
||||||
*/
|
*/
|
||||||
async updatePicture(): Promise<void> {
|
async updatePicture(): Promise<void> {
|
||||||
const updateArgs = {
|
window.history.replaceState(
|
||||||
data: { sasPictureId: this.currentPicture.id },
|
{ sasPictureId: this.currentPicture.id },
|
||||||
unused: "",
|
"",
|
||||||
url: this.currentPicture.sas_url,
|
this.currentPicture.sas_url,
|
||||||
};
|
);
|
||||||
if (this.pushstate === History.Replace) {
|
|
||||||
window.history.replaceState(updateArgs.data, updateArgs.unused, updateArgs.url);
|
|
||||||
this.pushstate = History.Push;
|
|
||||||
} else {
|
|
||||||
window.history.pushState(updateArgs.data, updateArgs.unused, updateArgs.url);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.moderationError = "";
|
this.moderationError = "";
|
||||||
const index: number = this.pictures.indexOf(this.currentPicture);
|
const index: number = this.pictures.indexOf(this.currentPicture);
|
||||||
|
|||||||
@@ -1,17 +1 @@
|
|||||||
# Register your models here.
|
# Register your models here.
|
||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
from timetable.models import Timetable, TimetableSlot
|
|
||||||
|
|
||||||
|
|
||||||
class TimetableSlotInline(admin.TabularInline):
|
|
||||||
model = TimetableSlot
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Timetable)
|
|
||||||
class TimetableAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("user", "semester")
|
|
||||||
search_fields = ("user__nick_name", "user__first_name", "user__last_name")
|
|
||||||
autocomplete_fields = ("user",)
|
|
||||||
inlines = (TimetableSlotInline,)
|
|
||||||
list_select_related = ("user",)
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
from ninja.security import SessionAuth
|
|
||||||
from ninja_extra import ControllerBase, api_controller, route
|
|
||||||
from ninja_extra.exceptions import PermissionDenied
|
|
||||||
|
|
||||||
from core.utils import get_semester_code
|
|
||||||
from timetable.models import Timetable, TimetableSlot
|
|
||||||
from timetable.schemas import CreateTimetableSchema, TimetableSchema
|
|
||||||
|
|
||||||
|
|
||||||
@api_controller("/edt", urls_namespace="timetable")
|
|
||||||
class TimetableController(ControllerBase):
|
|
||||||
@route.put(
|
|
||||||
"/{int:user_id}",
|
|
||||||
auth=SessionAuth(),
|
|
||||||
response={200: TimetableSchema},
|
|
||||||
url_name="save",
|
|
||||||
)
|
|
||||||
def save_timetable(self, user_id: int, data: CreateTimetableSchema):
|
|
||||||
user = self.context.request.user
|
|
||||||
if not user.has_perm("pedagogy.add_timetable") and not (
|
|
||||||
user.has_perm("timetable.add_self_timetable") and user.id == user_id
|
|
||||||
):
|
|
||||||
raise PermissionDenied
|
|
||||||
timetable, created = Timetable.objects.get_or_create(
|
|
||||||
semester=get_semester_code(), user_id=user_id
|
|
||||||
)
|
|
||||||
if not created:
|
|
||||||
timetable.slots.all().delete()
|
|
||||||
if user.preferences.show_my_timetable != data.is_viewable:
|
|
||||||
user.preferences.show_my_timetable = data.is_viewable
|
|
||||||
user.preferences.save()
|
|
||||||
slots = TimetableSlot.objects.bulk_create(
|
|
||||||
[TimetableSlot(timetable=timetable, **s.model_dump()) for s in data.slots]
|
|
||||||
)
|
|
||||||
return TimetableSchema(
|
|
||||||
id=timetable.id, semester=timetable.semester, slots=slots
|
|
||||||
)
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
# Generated by Django 5.2.17 on 2026-10-03 21:43
|
|
||||||
|
|
||||||
import django.core.validators
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
import core.utils
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="Timetable",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.BigAutoField(
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
verbose_name="ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"semester",
|
|
||||||
models.CharField(
|
|
||||||
blank=True,
|
|
||||||
default=core.utils.get_semester_code,
|
|
||||||
validators=[
|
|
||||||
django.core.validators.RegexValidator("(A|P)\\d{2}")
|
|
||||||
],
|
|
||||||
verbose_name="semester",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"user",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
related_name="timetables",
|
|
||||||
to=settings.AUTH_USER_MODEL,
|
|
||||||
verbose_name="user",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"verbose_name": "timetable",
|
|
||||||
"verbose_name_plural": "timetables",
|
|
||||||
"permissions": [("add_self_timetable", "Can add its own timetable")],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="TimetableSlot",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.BigAutoField(
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
verbose_name="ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"weekday",
|
|
||||||
models.SmallIntegerField(
|
|
||||||
choices=[
|
|
||||||
(1, "Monday"),
|
|
||||||
(2, "Tuesday"),
|
|
||||||
(3, "Wednesday"),
|
|
||||||
(4, "Thursday"),
|
|
||||||
(5, "Friday"),
|
|
||||||
(6, "Saturday"),
|
|
||||||
(7, "Sunday"),
|
|
||||||
],
|
|
||||||
verbose_name="weekday",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"start_at",
|
|
||||||
models.PositiveSmallIntegerField(
|
|
||||||
validators=[django.core.validators.MaxValueValidator(95)],
|
|
||||||
verbose_name="start at",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"end_at",
|
|
||||||
models.PositiveSmallIntegerField(
|
|
||||||
validators=[django.core.validators.MaxValueValidator(95)],
|
|
||||||
verbose_name="end at",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"course_type",
|
|
||||||
models.CharField(max_length=12, verbose_name="course type"),
|
|
||||||
),
|
|
||||||
("room", models.CharField(max_length=32, verbose_name="room")),
|
|
||||||
("ue", models.CharField(max_length=32, verbose_name="ue")),
|
|
||||||
(
|
|
||||||
"week_group",
|
|
||||||
models.CharField(
|
|
||||||
blank=True,
|
|
||||||
choices=[("A", "A"), ("B", "B")],
|
|
||||||
default="",
|
|
||||||
max_length=1,
|
|
||||||
verbose_name="week group",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"timetable",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
related_name="slots",
|
|
||||||
to="timetable.timetable",
|
|
||||||
verbose_name="timetable",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"verbose_name": "timetable slot",
|
|
||||||
"verbose_name_plural": "timetable slots",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name="timetable",
|
|
||||||
constraint=models.UniqueConstraint(
|
|
||||||
fields=("user", "semester"), name="timetable_unique_user_semester"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name="timetableslot",
|
|
||||||
constraint=models.CheckConstraint(
|
|
||||||
condition=models.Q(("end_at__gt", models.F("start_at"))),
|
|
||||||
name="timetable_slot_end_after_start",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,102 +1 @@
|
|||||||
# Create your models here.
|
# Create your models here.
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from django.core.validators import MaxValueValidator, RegexValidator
|
|
||||||
from django.db import models
|
|
||||||
from django.db.models import F, Q
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
from core.models import User
|
|
||||||
from core.utils import get_semester_code
|
|
||||||
|
|
||||||
|
|
||||||
class Timetable(models.Model):
|
|
||||||
user = models.ForeignKey(
|
|
||||||
User,
|
|
||||||
verbose_name=_("user"),
|
|
||||||
related_name="timetables",
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
)
|
|
||||||
semester = models.CharField(
|
|
||||||
_("semester"),
|
|
||||||
default=get_semester_code,
|
|
||||||
validators=[RegexValidator(r"(A|P)\d{2}")],
|
|
||||||
blank=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("timetable")
|
|
||||||
verbose_name_plural = _("timetables")
|
|
||||||
constraints = [
|
|
||||||
models.UniqueConstraint(
|
|
||||||
fields=("user", "semester"), name="timetable_unique_user_semester"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
permissions = [("add_self_timetable", "Can add its own timetable")]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"{self.user} {self.semester}"
|
|
||||||
|
|
||||||
|
|
||||||
class TimetableSlot(models.Model):
|
|
||||||
MINUTES_PER_SLOT = 15
|
|
||||||
|
|
||||||
class WeekDay(models.IntegerChoices):
|
|
||||||
MONDAY = 1
|
|
||||||
TUESDAY = 2
|
|
||||||
WEDNESDAY = 3
|
|
||||||
THURSDAY = 4
|
|
||||||
FRIDAY = 5
|
|
||||||
SATURDAY = 6
|
|
||||||
SUNDAY = 7
|
|
||||||
|
|
||||||
class WeekGroup(models.TextChoices):
|
|
||||||
A = "A"
|
|
||||||
B = "B"
|
|
||||||
BOTH = ""
|
|
||||||
|
|
||||||
timetable = models.ForeignKey(
|
|
||||||
Timetable,
|
|
||||||
verbose_name=_("timetable"),
|
|
||||||
related_name="slots",
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
)
|
|
||||||
weekday = models.SmallIntegerField(_("weekday"), choices=WeekDay)
|
|
||||||
# there are 4 * 24 = 96 quarters in a day, and we start counting at 0
|
|
||||||
start_at = models.PositiveSmallIntegerField(
|
|
||||||
_("start at"), validators=[MaxValueValidator(95)]
|
|
||||||
)
|
|
||||||
end_at = models.PositiveSmallIntegerField(
|
|
||||||
_("end at"), validators=[MaxValueValidator(95)]
|
|
||||||
)
|
|
||||||
course_type = models.CharField(_("course type"), max_length=12)
|
|
||||||
room = models.CharField(_("room"), max_length=32)
|
|
||||||
ue = models.CharField(_("ue"), max_length=32)
|
|
||||||
week_group = models.CharField(
|
|
||||||
_("week group"),
|
|
||||||
max_length=1,
|
|
||||||
choices=WeekGroup,
|
|
||||||
default=WeekGroup.BOTH,
|
|
||||||
blank=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("timetable slot")
|
|
||||||
verbose_name_plural = _("timetable slots")
|
|
||||||
constraints = [
|
|
||||||
models.CheckConstraint(
|
|
||||||
condition=Q(end_at__gt=F("start_at")),
|
|
||||||
name="timetable_slot_end_after_start",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"{self.start_hour} - {self.end_hour} - {self.get_weekday_display()}"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def start_hour(self):
|
|
||||||
return timedelta(minutes=self.start_at * self.MINUTES_PER_SLOT)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def end_hour(self):
|
|
||||||
return timedelta(minutes=self.end_at * self.MINUTES_PER_SLOT)
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from annotated_types import Ge, Le
|
|
||||||
from ninja import ModelSchema, Schema
|
|
||||||
from pydantic import Field
|
|
||||||
|
|
||||||
from timetable.models import Timetable, TimetableSlot
|
|
||||||
|
|
||||||
|
|
||||||
class TimetableSlotSchema(ModelSchema):
|
|
||||||
weekday: TimetableSlot.WeekDay = Field(
|
|
||||||
description=(
|
|
||||||
f"{TimetableSlot.WeekDay.MONDAY} is monday, "
|
|
||||||
f"{TimetableSlot.WeekDay.TUESDAY} is tuesday, etc."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
start_at: Annotated[int, Ge(0), Le(96)]
|
|
||||||
end_at: Annotated[int, Ge(0), Le(96)]
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = TimetableSlot
|
|
||||||
fields = ["start_at", "end_at", "week_group", "ue", "course_type", "room"]
|
|
||||||
|
|
||||||
|
|
||||||
class CreateTimetableSchema(Schema):
|
|
||||||
is_viewable: bool
|
|
||||||
slots: list[TimetableSlotSchema]
|
|
||||||
|
|
||||||
|
|
||||||
class TimetableSchema(ModelSchema):
|
|
||||||
slots: list[TimetableSlotSchema]
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Timetable
|
|
||||||
fields = ["id", "semester"]
|
|
||||||
+33
-89
@@ -1,6 +1,4 @@
|
|||||||
import html2canvas from "html2canvas";
|
import html2canvas from "html2canvas";
|
||||||
import { AlertMessage } from "#core:utils/alert-message";
|
|
||||||
import { type WeekDay as ApiWeekDay, timetableSaveTimetable } from "#openapi";
|
|
||||||
|
|
||||||
// see https://regex101.com/r/QHSaPM/3
|
// see https://regex101.com/r/QHSaPM/3
|
||||||
const TIMETABLE_ROW_RE: RegExp =
|
const TIMETABLE_ROW_RE: RegExp =
|
||||||
@@ -78,82 +76,17 @@ function parseSlots(s: string): TimetableSlot[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("alpine:init", () => {
|
document.addEventListener("alpine:init", () => {
|
||||||
/**
|
|
||||||
* Alpine data that can parse a timetable, save it and export it to png.
|
|
||||||
*/
|
|
||||||
Alpine.data("timetableGenerator", () => ({
|
Alpine.data("timetableGenerator", () => ({
|
||||||
content: DEFAULT_TIMETABLE,
|
content: DEFAULT_TIMETABLE,
|
||||||
error: null as { incorrectRow?: string } | null,
|
error: null as { incorrectRow?: string },
|
||||||
courses: [] as TimetableSlot[],
|
|
||||||
edtPublic: false,
|
|
||||||
saving: false,
|
|
||||||
timetableSavedMessage: new AlertMessage(),
|
|
||||||
|
|
||||||
generate() {
|
|
||||||
try {
|
|
||||||
this.courses = parseSlots(this.content);
|
|
||||||
this.error = null;
|
|
||||||
} catch (err) {
|
|
||||||
type ParseRowError = { cause: { row: string } };
|
|
||||||
this.error = { incorrectRow: (err as ParseRowError)?.cause?.row };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async savePng() {
|
|
||||||
const elem = document.getElementById("timetable");
|
|
||||||
const img = (await html2canvas(elem as HTMLElement)).toDataURL();
|
|
||||||
const downloadLink = document.createElement("a");
|
|
||||||
downloadLink.href = img;
|
|
||||||
downloadLink.download = "edt.png";
|
|
||||||
document.body.appendChild(downloadLink);
|
|
||||||
downloadLink.click();
|
|
||||||
downloadLink.remove();
|
|
||||||
},
|
|
||||||
|
|
||||||
async saveSith() {
|
|
||||||
this.saving = true;
|
|
||||||
const res = await timetableSaveTimetable({
|
|
||||||
body: {
|
|
||||||
// biome-ignore lint/style/useNamingConvention: api is snake case
|
|
||||||
is_viewable: this.edtPublic,
|
|
||||||
slots: this.courses.map((c) =>
|
|
||||||
Object.assign({}, c, {
|
|
||||||
// biome-ignore lint/style/useNamingConvention: api is snake case
|
|
||||||
start_at: c.startSlot,
|
|
||||||
// biome-ignore lint/style/useNamingConvention: api is snake case
|
|
||||||
end_at: c.endSlot,
|
|
||||||
weekday: WEEKDAYS.indexOf(c.weekday) + 1,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (res.response?.ok) {
|
|
||||||
this.timetableSavedMessage.display(
|
|
||||||
gettext("This timetable has been saved in your profile"),
|
|
||||||
{ success: true },
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.timetableSavedMessage.display(
|
|
||||||
interpolate(gettext("Error %d: timetable save failed"), res.response?.status),
|
|
||||||
{ success: false },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this.saving = false;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Alpine data dedicated to timetable display only.
|
|
||||||
* It can interact with the `timetableGenerator` data with x-model and x-modelable
|
|
||||||
* (for example of this, see timetable/generator.jinja)
|
|
||||||
*/
|
|
||||||
Alpine.data("timetableDisplay", (slots: TimetableSlot[]) => ({
|
|
||||||
displayedWeekdays: [] as WeekDay[],
|
displayedWeekdays: [] as WeekDay[],
|
||||||
|
courses: [] as TimetableSlot[],
|
||||||
startSlot: 0,
|
startSlot: 0,
|
||||||
endSlot: 0,
|
endSlot: 0,
|
||||||
table: { height: 0, width: 0 },
|
table: {
|
||||||
slots: slots,
|
height: 0,
|
||||||
|
width: 0,
|
||||||
|
},
|
||||||
|
|
||||||
colors: {} as Record<string, string>,
|
colors: {} as Record<string, string>,
|
||||||
colorPalette: [
|
colorPalette: [
|
||||||
@@ -170,14 +103,18 @@ document.addEventListener("alpine:init", () => {
|
|||||||
"#1b5e20",
|
"#1b5e20",
|
||||||
],
|
],
|
||||||
|
|
||||||
init() {
|
generate() {
|
||||||
this.$watch("slots", () => this.display());
|
try {
|
||||||
},
|
this.courses = parseSlots(this.content);
|
||||||
|
this.error = null;
|
||||||
|
} catch (err) {
|
||||||
|
this.error = { incorrectRow: err?.cause?.row };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
display() {
|
|
||||||
// color each UE
|
// color each UE
|
||||||
let colorIndex = 0;
|
let colorIndex = 0;
|
||||||
for (const slot of this.slots) {
|
for (const slot of this.courses) {
|
||||||
if (!this.colors[slot.ueCode]) {
|
if (!this.colors[slot.ueCode]) {
|
||||||
this.colors[slot.ueCode] =
|
this.colors[slot.ueCode] =
|
||||||
this.colorPalette[colorIndex % this.colorPalette.length];
|
this.colorPalette[colorIndex % this.colorPalette.length];
|
||||||
@@ -186,13 +123,13 @@ document.addEventListener("alpine:init", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.displayedWeekdays = WEEKDAYS.filter((day) =>
|
this.displayedWeekdays = WEEKDAYS.filter((day) =>
|
||||||
this.slots.some((slot: TimetableSlot) => slot.weekday === day),
|
this.courses.some((slot: TimetableSlot) => slot.weekday === day),
|
||||||
);
|
);
|
||||||
this.startSlot = this.slots.reduce(
|
this.startSlot = this.courses.reduce(
|
||||||
(acc: number, curr: TimetableSlot) => Math.min(acc, curr.startSlot),
|
(acc: number, curr: TimetableSlot) => Math.min(acc, curr.startSlot),
|
||||||
25 * 4,
|
25 * 4,
|
||||||
);
|
);
|
||||||
this.endSlot = this.slots.reduce(
|
this.endSlot = this.courses.reduce(
|
||||||
(acc: number, curr: TimetableSlot) => Math.max(acc, curr.endSlot),
|
(acc: number, curr: TimetableSlot) => Math.max(acc, curr.endSlot),
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
@@ -201,7 +138,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getStyle(slot: TimetableSlot) {
|
getStyle(slot: TimetableSlot) {
|
||||||
const hasWeekGroup = !!slot.weekGroup;
|
const hasWeekGroup = slot.weekGroup !== undefined;
|
||||||
const width = hasWeekGroup ? SLOT_WIDTH / 2 : SLOT_WIDTH;
|
const width = hasWeekGroup ? SLOT_WIDTH / 2 : SLOT_WIDTH;
|
||||||
const leftOffset = slot.weekGroup === "B" ? SLOT_WIDTH / 2 : 0;
|
const leftOffset = slot.weekGroup === "B" ? SLOT_WIDTH / 2 : 0;
|
||||||
return {
|
return {
|
||||||
@@ -213,19 +150,15 @@ document.addEventListener("alpine:init", () => {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
getHours(): [string, object][] {
|
||||||
* Return the hours that should be displayed on this timetable,
|
|
||||||
* with their `top` placing
|
|
||||||
*/
|
|
||||||
getHours(): [string, { top: `${number}px` }][] {
|
|
||||||
let hour: number = Number.parseInt(
|
let hour: number = Number.parseInt(
|
||||||
this.slots
|
this.courses
|
||||||
.map((c: TimetableSlot) => c.startHour)
|
.map((c: TimetableSlot) => c.startHour)
|
||||||
.reduce((res: string, hour: string) => (hour < res ? hour : res), "24:00")
|
.reduce((res: string, hour: string) => (hour < res ? hour : res), "24:00")
|
||||||
.split(":")[0],
|
.split(":")[0],
|
||||||
10,
|
10,
|
||||||
);
|
);
|
||||||
const res: [string, { top: `${number}px` }][] = [];
|
const res: [string, object][] = [];
|
||||||
for (let i = 0; i <= this.endSlot - this.startSlot; i += 60 / MINUTES_PER_SLOT) {
|
for (let i = 0; i <= this.endSlot - this.startSlot; i += 60 / MINUTES_PER_SLOT) {
|
||||||
res.push([`${hour}:00`, { top: `${i * SLOT_HEIGHT}px` }]);
|
res.push([`${hour}:00`, { top: `${i * SLOT_HEIGHT}px` }]);
|
||||||
hour += 1;
|
hour += 1;
|
||||||
@@ -236,5 +169,16 @@ document.addEventListener("alpine:init", () => {
|
|||||||
getWidth() {
|
getWidth() {
|
||||||
return this.displayedWeekdays.length * SLOT_WIDTH + 20;
|
return this.displayedWeekdays.length * SLOT_WIDTH + 20;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async savePng() {
|
||||||
|
const elem = document.getElementById("timetable");
|
||||||
|
const img = (await html2canvas(elem as HTMLElement)).toDataURL();
|
||||||
|
const downloadLink = document.createElement("a");
|
||||||
|
downloadLink.href = img;
|
||||||
|
downloadLink.download = "edt.png";
|
||||||
|
document.body.appendChild(downloadLink);
|
||||||
|
downloadLink.click();
|
||||||
|
downloadLink.remove();
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
@import "core/static/core/colors";
|
@import "core/static/core/colors";
|
||||||
|
|
||||||
.timetable {
|
#timetable {
|
||||||
--hour-side-width: 60px;
|
--hour-side-width: 60px;
|
||||||
|
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
{% extends "core/base.jinja" %}
|
{% extends 'core/base.jinja' %}
|
||||||
|
|
||||||
{% from "timetable/macros.jinja" import display_timetable %}
|
|
||||||
|
|
||||||
{%- block additional_css -%}
|
{%- block additional_css -%}
|
||||||
<link rel="stylesheet" href="{{ static('timetable/css/generator.scss') }}">
|
<link rel="stylesheet" href="{{ static('timetable/css/generator.scss') }}">
|
||||||
{%- endblock -%}
|
{%- endblock -%}
|
||||||
|
|
||||||
{%- block additional_js -%}
|
{%- block additional_js -%}
|
||||||
<script type="module" src="{{ static('bundled/timetable/timetable-index.ts') }}"></script>
|
<script type="module" src="{{ static('bundled/timetable/generator-index.ts') }}"></script>
|
||||||
{%- endblock -%}
|
{%- endblock -%}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
@@ -62,40 +60,44 @@
|
|||||||
</div>
|
</div>
|
||||||
<input type="submit" class="btn btn-blue" value="{% trans %}Generate{% endtrans %}">
|
<input type="submit" class="btn btn-blue" value="{% trans %}Generate{% endtrans %}">
|
||||||
</form>
|
</form>
|
||||||
{{ display_timetable("courses") }}
|
<div
|
||||||
<template x-if="courses.length > 0">
|
id="timetable"
|
||||||
<section>
|
x-show="table.height > 0 && table.width > 0"
|
||||||
{% if user.has_perm("timetable.add_self_timetable") %}
|
:style="{width: `${table.width+80}px`, height: `${table.height+40}px`}"
|
||||||
<div id="timetable-save" class="margin-bottom">
|
>
|
||||||
<h3>{% trans %}Save on your profile{% endtrans %}</h3>
|
<div class="header">
|
||||||
<form>
|
<template x-for="weekday in displayedWeekdays">
|
||||||
<fieldset>
|
<span x-text="weekday"></span>
|
||||||
<input type="checkbox" class="switch" id="edt-public-switch-id" x-model="edtPublic">
|
</template>
|
||||||
<label for="edt-public-switch-id">{% trans %}Make this timetable public{% endtrans %}</label>
|
</div>
|
||||||
<span class="helptext">
|
<div class="content">
|
||||||
{% trans trimmed url=url("core:user_prefs", user_id=user.id) %}
|
<div class="hours" :height="(endSlot - endSlot%4) - (startSlot - startSlot%4)">
|
||||||
You can change this at any time in <a href="{{ url }}">your preferences</a>
|
<template x-for="[hour, style] in getHours()">
|
||||||
{% endtrans %}
|
<div class="hour" :style="style">
|
||||||
</span>
|
<div x-text="hour"></div>
|
||||||
</fieldset>
|
<div class="hour-bar" :style="{width: `${getWidth()}px`}"></div>
|
||||||
<button class="btn btn-blue" :disabled="saving" @click.prevent="saveSith">
|
</div>
|
||||||
{% trans %}Save timetable{% endtrans %}
|
</template>
|
||||||
</button>
|
</div>
|
||||||
<p
|
<div class="courses">
|
||||||
class="alert"
|
<template x-for="course in courses">
|
||||||
:class="timetableSavedMessage.success ? 'alert-green' : 'alert-red'"
|
<div class="slot" :style="getStyle(course)">
|
||||||
x-show="timetableSavedMessage.open"
|
<span class="course-type" x-text="course.courseType"></span>
|
||||||
x-transition.duration.500ms
|
<span x-text="course.ueCode"></span>
|
||||||
x-text="timetableSavedMessage.content"
|
<span x-text="`${course.startHour} - ${course.endHour}`"></span>
|
||||||
></p>
|
<span x-text="(course.weekGroup ? `\nGroupe ${course.weekGroup}` : '')"></span>
|
||||||
</form>
|
<span x-text="course.room"></span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
</template>
|
||||||
<h3 class="margin-bottom">{% trans %}Export{% endtrans %}</h3>
|
</div>
|
||||||
<button class="margin-bottom btn btn-blue" @click="savePng">
|
</div>
|
||||||
{% trans %}Export to PNG{% endtrans %}
|
</div>
|
||||||
</button>
|
<button
|
||||||
</section>
|
class="margin-bottom btn btn-blue"
|
||||||
</template>
|
@click="savePng"
|
||||||
|
x-show="table.height > 0 && table.width > 0"
|
||||||
|
>
|
||||||
|
{% trans %}Save to PNG{% endtrans %}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
{# HTML+AlpineJS to display a timetable
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ues: either a JS variable name or an array of object
|
|
||||||
satisfying the TimetableSlot interface
|
|
||||||
reactive: if True and `ues` is a variable used in an Alpine context,
|
|
||||||
then this timetable will react to modifications and
|
|
||||||
change its own display
|
|
||||||
#}
|
|
||||||
{% macro display_timetable(ues) %}
|
|
||||||
<div
|
|
||||||
x-data="timetableDisplay({{ ues }})"
|
|
||||||
x-model="{{ ues }}"
|
|
||||||
x-modelable="slots"
|
|
||||||
class="timetable"
|
|
||||||
x-show="slots.length > 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 slots">
|
|
||||||
<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>
|
|
||||||
{% endmacro %}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
{% extends "core/base.jinja" %}
|
|
||||||
|
|
||||||
{% from "timetable/macros.jinja" import display_timetable %}
|
|
||||||
|
|
||||||
|
|
||||||
{%- block additional_css -%}
|
|
||||||
<link rel="stylesheet" href="{{ static('timetable/css/generator.scss') }}">
|
|
||||||
{%- endblock -%}
|
|
||||||
|
|
||||||
{%- block additional_js -%}
|
|
||||||
<script type="module" src="{{ static('bundled/timetable/timetable-index.ts') }}"></script>
|
|
||||||
{%- endblock -%}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
{% for timetable in timetables %}
|
|
||||||
<details
|
|
||||||
class="accordion"
|
|
||||||
name="{{ timetable.semester }}"
|
|
||||||
{% if loop.first %}open{% endif %}
|
|
||||||
>
|
|
||||||
<summary>{{ timetable.semester }}</summary>
|
|
||||||
<div class="accordion-content">
|
|
||||||
{{ display_timetable(timetable.semester) }}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
{% endfor %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{%- block script -%}
|
|
||||||
{% set weekdays = ["lundi","mardi","mercredi","jeudi","vendredi","samedi","dimanche"] %}
|
|
||||||
<script>
|
|
||||||
{%- for timetable in timetables -%}
|
|
||||||
const {{ timetable.semester }} = [
|
|
||||||
{%- for slot in timetable.slots.all() -%}
|
|
||||||
{
|
|
||||||
courseType: "{{ slot.course_type }}",
|
|
||||||
room: "{{ slot.room }}",
|
|
||||||
startHour: "{{ "%02d:%02d" % (slot.start_hour.seconds // 3600, (slot.start_hour.seconds % 3600 ) // 60) }}",
|
|
||||||
endHour: "{{ "%02d:%02d" % (slot.end_hour.seconds // 3600, (slot.end_hour.seconds % 3600 ) // 60) }}",
|
|
||||||
startSlot: {{ slot.start_at }},
|
|
||||||
endSlot: {{ slot.end_at }},
|
|
||||||
ueCode: "{{ slot.ue }}",
|
|
||||||
{%- if slot.week_group -%}
|
|
||||||
weekGroup: "{{ slot.week_group }}",
|
|
||||||
{%- endif -%}
|
|
||||||
weekday: "{{ weekdays[slot.weekday] }}"
|
|
||||||
},
|
|
||||||
{%- endfor -%}
|
|
||||||
];
|
|
||||||
{%- endfor -%}
|
|
||||||
</script>
|
|
||||||
{%- endblock -%}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Create your tests here.
|
||||||
Whitespace-only changes.
@@ -1,20 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
from core.baker_recipes import subscriber_user
|
|
||||||
from timetable.models import TimetableSlot
|
|
||||||
|
|
||||||
|
|
||||||
class TestSaveTimetable(TestCase):
|
|
||||||
@classmethod
|
|
||||||
def setUpTestData(cls) -> None:
|
|
||||||
cls.user = subscriber_user.make()
|
|
||||||
cls.payload = {
|
|
||||||
"is_viewable": True,
|
|
||||||
"slots": [
|
|
||||||
{"weekday": TimetableSlot.WeekDay.MONDAY, "start_at": 32, "end_at": 40},
|
|
||||||
{"weekday": TimetableSlot.WeekDay.MONDAY, "start_at": 41, "end_at": 49},
|
|
||||||
{"weekday": TimetableSlot.WeekDay.FRIDAY, "start_at": 52, "end_at": 64},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
cls.url = reverse("api:timetable:save", kwargs={"user_id": cls.user.id})
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from django.contrib.auth.models import Permission
|
|
||||||
from django.urls import reverse
|
|
||||||
from model_bakery import baker
|
|
||||||
|
|
||||||
from core.models import User
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from django.test import Client
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
@pytest.mark.parametrize("can_save", [True, False])
|
|
||||||
def test_generator_ok(client: Client, can_save):
|
|
||||||
user = baker.make(User)
|
|
||||||
if can_save:
|
|
||||||
user.user_permissions.add(Permission.objects.get(codename="add_self_timetable"))
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
res = client.get(reverse("timetable:generator"))
|
|
||||||
assert res.status_code == 200
|
|
||||||
soup = BeautifulSoup(res.text, "lxml")
|
|
||||||
elem = soup.find(id="timetable-save")
|
|
||||||
|
|
||||||
if can_save:
|
|
||||||
assert elem is not None
|
|
||||||
else:
|
|
||||||
assert elem is None
|
|
||||||
+2
-5
@@ -1,8 +1,5 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from timetable.views import GeneratorView, UserTimetableView
|
from timetable.views import GeneratorView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [path("", GeneratorView.as_view(), name="generator")]
|
||||||
path("", GeneratorView.as_view(), name="generator"),
|
|
||||||
path("user/<int:user_id>", UserTimetableView.as_view(), name="user_timetable"),
|
|
||||||
]
|
|
||||||
+2
-50
@@ -1,54 +1,6 @@
|
|||||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
# Create your views here.
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
from django.views.generic.detail import DetailView
|
|
||||||
|
|
||||||
from core.models import User
|
|
||||||
from core.views import TabedViewMixin
|
|
||||||
|
|
||||||
|
|
||||||
class TimetableTabsMixin(TabedViewMixin):
|
class GeneratorView(TemplateView):
|
||||||
tabs_title = _("Timetables")
|
|
||||||
|
|
||||||
def get_list_of_tabs(self):
|
|
||||||
res = [
|
|
||||||
{
|
|
||||||
"url": reverse("timetable:generator"),
|
|
||||||
"slug": "generator",
|
|
||||||
"name": _("Generator"),
|
|
||||||
}
|
|
||||||
]
|
|
||||||
if self.request.user.timetables.exists():
|
|
||||||
res.append(
|
|
||||||
{
|
|
||||||
"url": reverse(
|
|
||||||
"core:user_timetable", kwargs={"user_id": self.request.user.id}
|
|
||||||
),
|
|
||||||
"slug": "saved",
|
|
||||||
"name": _("My timetables"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
class GeneratorView(TimetableTabsMixin, TemplateView):
|
|
||||||
template_name = "timetable/generator.jinja"
|
template_name = "timetable/generator.jinja"
|
||||||
current_tab = "generator"
|
|
||||||
|
|
||||||
|
|
||||||
class UserTimetableView(UserPassesTestMixin, TimetableTabsMixin, DetailView):
|
|
||||||
model = User
|
|
||||||
pk_url_kwarg = "user_id"
|
|
||||||
template_name = "timetable/user_timetable.jinja"
|
|
||||||
current_tab = "saved"
|
|
||||||
|
|
||||||
def test_func(self) -> bool | None:
|
|
||||||
return self.request.user.id == self.kwargs[
|
|
||||||
"user_id"
|
|
||||||
] or self.request.user.has_perm("timetable.view_timetable")
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
|
||||||
return super().get_context_data(**kwargs) | {
|
|
||||||
"timetables": list(self.object.timetables.prefetch_related("slots"))
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user