mirror of
				https://github.com/ae-utbm/sith.git
				synced 2025-11-04 11:03:04 +00:00 
			
		
		
		
	Merge pull request #1107 from ae-utbm/taiste
Eboutic refactor, Celery, better tooltips, Python 3.13, bugfixes and other
This commit is contained in:
		@@ -10,6 +10,7 @@ DATABASE_URL=sqlite:///db.sqlite3
 | 
			
		||||
 | 
			
		||||
REDIS_PORT=7963
 | 
			
		||||
CACHE_URL=redis://127.0.0.1:${REDIS_PORT}/0
 | 
			
		||||
TASK_BROKER_URL=redis://127.0.0.1:${REDIS_PORT}/1
 | 
			
		||||
 | 
			
		||||
# Used to select which other services to run alongside
 | 
			
		||||
# manage.py, pytest and runserver
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							@@ -11,6 +11,7 @@ env:
 | 
			
		||||
  SECRET_KEY: notTheRealOne
 | 
			
		||||
  DATABASE_URL: sqlite:///db.sqlite3
 | 
			
		||||
  CACHE_URL: redis://127.0.0.1:6379/0
 | 
			
		||||
  TASK_BROKER_URL: redis://127.0.0.1:6379/1
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  pre-commit:
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,10 @@
 | 
			
		||||
repos:
 | 
			
		||||
  - repo: https://github.com/astral-sh/ruff-pre-commit
 | 
			
		||||
    # Ruff version.
 | 
			
		||||
    rev: v0.11.4
 | 
			
		||||
    rev: v0.11.11
 | 
			
		||||
    hooks:
 | 
			
		||||
      - id: ruff  # just check the code, and print the errors
 | 
			
		||||
      - id: ruff  # actually fix the fixable errors, but print nothing
 | 
			
		||||
      - id: ruff-check  # just check the code, and print the errors
 | 
			
		||||
      - id: ruff-check  # actually fix the fixable errors, but print nothing
 | 
			
		||||
        args: ["--fix", "--silent"]
 | 
			
		||||
      # Run the formatter.
 | 
			
		||||
      - id: ruff-format
 | 
			
		||||
 
 | 
			
		||||
@@ -1 +1 @@
 | 
			
		||||
3.12
 | 
			
		||||
3.13
 | 
			
		||||
@@ -1 +1,2 @@
 | 
			
		||||
redis: redis-server --port $REDIS_PORT
 | 
			
		||||
celery: uv run celery -A sith worker --beat -l INFO
 | 
			
		||||
@@ -24,13 +24,15 @@
 | 
			
		||||
 | 
			
		||||
from django import forms
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db.models import Exists, OuterRef, Q
 | 
			
		||||
from django.db.models.functions import Lower
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
 | 
			
		||||
from club.models import Club, Mailing, MailingSubscription, Membership
 | 
			
		||||
from core.models import User
 | 
			
		||||
from core.views.forms import SelectDate, SelectDateTime
 | 
			
		||||
from core.views.widgets.ajax_select import AutoCompleteSelectMultipleUser
 | 
			
		||||
from counter.models import Counter
 | 
			
		||||
from counter.models import Counter, Selling
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ClubEditForm(forms.ModelForm):
 | 
			
		||||
@@ -159,12 +161,20 @@ class SellingsForm(forms.Form):
 | 
			
		||||
        label=_("End date"), widget=SelectDateTime, required=False
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    counters = forms.ModelMultipleChoiceField(
 | 
			
		||||
        Counter.objects.order_by("name").all(), label=_("Counter"), required=False
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def __init__(self, club, *args, **kwargs):
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
        counters_qs = (
 | 
			
		||||
            Counter.objects.filter(
 | 
			
		||||
                Q(club=club)
 | 
			
		||||
                | Q(products__club=club)
 | 
			
		||||
                | Exists(Selling.objects.filter(counter=OuterRef("pk"), club=club))
 | 
			
		||||
            )
 | 
			
		||||
            .distinct()
 | 
			
		||||
            .order_by(Lower("name"))
 | 
			
		||||
        )
 | 
			
		||||
        self.fields["counters"] = forms.ModelMultipleChoiceField(
 | 
			
		||||
            counters_qs, label=_("Counter"), required=False
 | 
			
		||||
        )
 | 
			
		||||
        self.fields["products"] = forms.ModelMultipleChoiceField(
 | 
			
		||||
            club.products.order_by("name").filter(archived=False).all(),
 | 
			
		||||
            label=_("Products"),
 | 
			
		||||
 
 | 
			
		||||
@@ -16,22 +16,13 @@
 | 
			
		||||
    </ul>
 | 
			
		||||
    <h4>{% trans %}Counters:{% endtrans %}</h4>
 | 
			
		||||
    <ul>
 | 
			
		||||
      {% if object.id == settings.SITH_LAUNDERETTE_CLUB_ID %}
 | 
			
		||||
        {% for l in Launderette.objects.all() %}
 | 
			
		||||
          <li><a href="{{ url('launderette:main_click', launderette_id=l.id) }}">{{ l }}</a></li>
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
      {% elif object.counters.filter(type="OFFICE")|count > 0 %}
 | 
			
		||||
        {% for c in object.counters.filter(type="OFFICE") %}
 | 
			
		||||
          <li>{{ c }}:
 | 
			
		||||
            <a href="{{ url('counter:details', counter_id=c.id) }}">View</a>
 | 
			
		||||
            <a href="{{ url('counter:admin', counter_id=c.id) }}">Edit</a>
 | 
			
		||||
          </li>
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
      {% endif %}
 | 
			
		||||
      {% for c in object.counters.filter(type="OFFICE") %}
 | 
			
		||||
        <li>{{ c }}:
 | 
			
		||||
          <a href="{{ url('counter:details', counter_id=c.id) }}">View</a>
 | 
			
		||||
          <a href="{{ url('counter:admin', counter_id=c.id) }}">Edit</a>
 | 
			
		||||
        </li>
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
    </ul>
 | 
			
		||||
    {% if object.id == settings.SITH_LAUNDERETTE_CLUB_ID %}
 | 
			
		||||
      <li><a href="{{ url('launderette:launderette_list') }}">{% trans %}Manage launderettes{% endtrans %}</a></li>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
  </div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -3,8 +3,11 @@ from django.test import Client
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from model_bakery import baker
 | 
			
		||||
 | 
			
		||||
from club.forms import SellingsForm
 | 
			
		||||
from club.models import Club
 | 
			
		||||
from core.models import User
 | 
			
		||||
from counter.baker_recipes import product_recipe, sale_recipe
 | 
			
		||||
from counter.models import Counter, Customer
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.django_db
 | 
			
		||||
@@ -14,3 +17,22 @@ def test_sales_page_doesnt_crash(client: Client):
 | 
			
		||||
    client.force_login(admin)
 | 
			
		||||
    response = client.get(reverse("club:club_sellings", kwargs={"club_id": club.id}))
 | 
			
		||||
    assert response.status_code == 200
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.django_db
 | 
			
		||||
def test_sales_form_counter_filter():
 | 
			
		||||
    """Test that counters are properly filtered in SellingsForm"""
 | 
			
		||||
    club = baker.make(Club)
 | 
			
		||||
    counters = baker.make(
 | 
			
		||||
        Counter, _quantity=5, _bulk_create=True, name=iter(["Z", "a", "B", "e", "f"])
 | 
			
		||||
    )
 | 
			
		||||
    counters[0].club = club
 | 
			
		||||
    counters[0].save()
 | 
			
		||||
    sale_recipe.make(
 | 
			
		||||
        counter=counters[1], club=club, unit_price=0, customer=baker.make(Customer)
 | 
			
		||||
    )
 | 
			
		||||
    product_recipe.make(counters=[counters[2]], club=club)
 | 
			
		||||
 | 
			
		||||
    form = SellingsForm(club)
 | 
			
		||||
    form_counters = list(form.fields["counters"].queryset)
 | 
			
		||||
    assert form_counters == [counters[1], counters[2], counters[0]]
 | 
			
		||||
 
 | 
			
		||||
@@ -17,11 +17,12 @@ import {
 | 
			
		||||
 | 
			
		||||
@registerComponent("ics-calendar")
 | 
			
		||||
export class IcsCalendar extends inheritHtmlElement("div") {
 | 
			
		||||
  static observedAttributes = ["locale", "can_moderate", "can_delete"];
 | 
			
		||||
  static observedAttributes = ["locale", "can_moderate", "can_delete", "ics-help-url"];
 | 
			
		||||
  private calendar: Calendar;
 | 
			
		||||
  private locale = "en";
 | 
			
		||||
  private canModerate = false;
 | 
			
		||||
  private canDelete = false;
 | 
			
		||||
  private helpUrl = "";
 | 
			
		||||
 | 
			
		||||
  attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
 | 
			
		||||
    if (name === "locale") {
 | 
			
		||||
@@ -33,6 +34,10 @@ export class IcsCalendar extends inheritHtmlElement("div") {
 | 
			
		||||
    if (name === "can_delete") {
 | 
			
		||||
      this.canDelete = newValue.toLowerCase() === "true";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (name === "ics-help-url") {
 | 
			
		||||
      this.helpUrl = newValue;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  isMobile() {
 | 
			
		||||
@@ -48,11 +53,11 @@ export class IcsCalendar extends inheritHtmlElement("div") {
 | 
			
		||||
    if (this.isMobile()) {
 | 
			
		||||
      return {
 | 
			
		||||
        start: "",
 | 
			
		||||
        center: "getCalendarLink",
 | 
			
		||||
        center: "getCalendarLink helpButton",
 | 
			
		||||
        end: "",
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
    return { start: "getCalendarLink", center: "", end: "" };
 | 
			
		||||
    return { start: "getCalendarLink helpButton", center: "", end: "" };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  currentHeaderToolbar() {
 | 
			
		||||
@@ -87,15 +92,8 @@ export class IcsCalendar extends inheritHtmlElement("div") {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async refreshEvents() {
 | 
			
		||||
  refreshEvents() {
 | 
			
		||||
    this.click(); // Remove focus from popup
 | 
			
		||||
    // We can't just refresh events because some ics files are in
 | 
			
		||||
    // local browser cache (especially internal.ics)
 | 
			
		||||
    // To invalidate the cache, we need to remove the source and add it again
 | 
			
		||||
    this.calendar.removeAllEventSources();
 | 
			
		||||
    for (const source of await this.getEventSources()) {
 | 
			
		||||
      this.calendar.addEventSource(source);
 | 
			
		||||
    }
 | 
			
		||||
    this.calendar.refetchEvents();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -114,7 +112,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
 | 
			
		||||
        },
 | 
			
		||||
      }),
 | 
			
		||||
    );
 | 
			
		||||
    await this.refreshEvents();
 | 
			
		||||
    this.refreshEvents();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async unpublishNews(id: number) {
 | 
			
		||||
@@ -132,7 +130,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
 | 
			
		||||
        },
 | 
			
		||||
      }),
 | 
			
		||||
    );
 | 
			
		||||
    await this.refreshEvents();
 | 
			
		||||
    this.refreshEvents();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async deleteNews(id: number) {
 | 
			
		||||
@@ -150,22 +148,23 @@ export class IcsCalendar extends inheritHtmlElement("div") {
 | 
			
		||||
        },
 | 
			
		||||
      }),
 | 
			
		||||
    );
 | 
			
		||||
    await this.refreshEvents();
 | 
			
		||||
    this.refreshEvents();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getEventSources() {
 | 
			
		||||
    const cacheInvalidate = `?invalidate=${Date.now()}`;
 | 
			
		||||
    return [
 | 
			
		||||
      {
 | 
			
		||||
        url: `${await makeUrl(calendarCalendarInternal)}${cacheInvalidate}`,
 | 
			
		||||
        url: `${await makeUrl(calendarCalendarInternal)}`,
 | 
			
		||||
        format: "ics",
 | 
			
		||||
        className: "internal",
 | 
			
		||||
        cache: false,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        url: `${await makeUrl(calendarCalendarUnpublished)}${cacheInvalidate}`,
 | 
			
		||||
        url: `${await makeUrl(calendarCalendarUnpublished)}`,
 | 
			
		||||
        format: "ics",
 | 
			
		||||
        color: "red",
 | 
			
		||||
        className: "unpublished",
 | 
			
		||||
        cache: false,
 | 
			
		||||
      },
 | 
			
		||||
    ];
 | 
			
		||||
  }
 | 
			
		||||
@@ -320,14 +319,14 @@ export class IcsCalendar extends inheritHtmlElement("div") {
 | 
			
		||||
          click: async (event: Event) => {
 | 
			
		||||
            const button = event.target as HTMLButtonElement;
 | 
			
		||||
            button.classList.add("text-copy");
 | 
			
		||||
            if (!button.hasAttribute("position")) {
 | 
			
		||||
              button.setAttribute("tooltip", gettext("Link copied"));
 | 
			
		||||
              button.setAttribute("position", "top");
 | 
			
		||||
              button.setAttribute("no-hover", "");
 | 
			
		||||
            button.setAttribute("tooltip-class", "calendar-copy-tooltip");
 | 
			
		||||
            if (!button.hasAttribute("tooltip-position")) {
 | 
			
		||||
              button.setAttribute("tooltip-position", "top");
 | 
			
		||||
            }
 | 
			
		||||
            if (button.classList.contains("text-copied")) {
 | 
			
		||||
              button.classList.remove("text-copied");
 | 
			
		||||
            }
 | 
			
		||||
            button.setAttribute("tooltip", gettext("Link copied"));
 | 
			
		||||
            navigator.clipboard.writeText(
 | 
			
		||||
              new URL(
 | 
			
		||||
                await makeUrl(calendarCalendarInternal),
 | 
			
		||||
@@ -335,12 +334,22 @@ export class IcsCalendar extends inheritHtmlElement("div") {
 | 
			
		||||
              ).toString(),
 | 
			
		||||
            );
 | 
			
		||||
            setTimeout(() => {
 | 
			
		||||
              button.setAttribute("tooltip-class", "calendar-copy-tooltip text-copied");
 | 
			
		||||
              button.classList.remove("text-copied");
 | 
			
		||||
              button.classList.add("text-copied");
 | 
			
		||||
              button.classList.remove("text-copy");
 | 
			
		||||
            }, 1500);
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        helpButton: {
 | 
			
		||||
          text: "?",
 | 
			
		||||
          hint: gettext("How to use calendar link"),
 | 
			
		||||
          click: () => {
 | 
			
		||||
            if (this.helpUrl) {
 | 
			
		||||
              window.open(this.helpUrl, "_blank");
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      height: "auto",
 | 
			
		||||
      locale: this.locale,
 | 
			
		||||
@@ -348,6 +357,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
 | 
			
		||||
      headerToolbar: this.currentHeaderToolbar(),
 | 
			
		||||
      footerToolbar: this.currentFooterToolbar(),
 | 
			
		||||
      eventSources: await this.getEventSources(),
 | 
			
		||||
      lazyFetching: false,
 | 
			
		||||
      windowResize: () => {
 | 
			
		||||
        this.calendar.changeView(this.currentView());
 | 
			
		||||
        this.calendar.setOption("headerToolbar", this.currentHeaderToolbar());
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
@import "core/static/core/colors";
 | 
			
		||||
@import "core/static/core/tooltips";
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
:root {
 | 
			
		||||
@@ -116,8 +117,33 @@ ics-calendar {
 | 
			
		||||
    transition: 500ms ease-out;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  button.text-copied[tooltip]::before {
 | 
			
		||||
    opacity: 0;
 | 
			
		||||
    transition: opacity 500ms ease-out;
 | 
			
		||||
  .fc .fc-getCalendarLink-button {
 | 
			
		||||
    margin-right: 0.5rem;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .fc .fc-helpButton-button {
 | 
			
		||||
    border-radius: 70%;
 | 
			
		||||
    padding-left: 0.5rem;
 | 
			
		||||
    padding-right: 0.5rem;
 | 
			
		||||
    background-color: rgba(0, 0, 0, 0.8);
 | 
			
		||||
    transition: 100ms ease-out;
 | 
			
		||||
    width: 30px;
 | 
			
		||||
    height: 30px;
 | 
			
		||||
    font-size: 11px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  .fc .fc-helpButton-button:hover {
 | 
			
		||||
    background-color: rgba(20, 20, 20, 0.6);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tooltip.calendar-copy-tooltip {
 | 
			
		||||
  opacity: 1;
 | 
			
		||||
  transition: opacity 500ms ease-in;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tooltip.calendar-copy-tooltip.text-copied {
 | 
			
		||||
  opacity: 0;
 | 
			
		||||
  transition: opacity 500ms ease-out;
 | 
			
		||||
}
 | 
			
		||||
@@ -192,6 +192,7 @@
 | 
			
		||||
        @calendar-delete="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.DELETED})"
 | 
			
		||||
        @calendar-unpublish="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.PENDING})"
 | 
			
		||||
        @calendar-publish="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.PUBLISHED})"
 | 
			
		||||
        ics-help-url="{{ url('core:page', page_name='Index/calendrier')}}"
 | 
			
		||||
        locale="{{ get_language() }}"
 | 
			
		||||
        can_moderate="{{ user.has_perm("com.moderate_news") }}"
 | 
			
		||||
        can_delete="{{ user.has_perm("com.delete_news") }}"
 | 
			
		||||
 
 | 
			
		||||
@@ -32,7 +32,6 @@ class SithConfig(AppConfig):
 | 
			
		||||
    verbose_name = "Core app of the Sith"
 | 
			
		||||
 | 
			
		||||
    def ready(self):
 | 
			
		||||
        import core.signals  # noqa F401
 | 
			
		||||
        from forum.models import Forum
 | 
			
		||||
 | 
			
		||||
        cache.clear()
 | 
			
		||||
 
 | 
			
		||||
@@ -123,11 +123,6 @@ class Command(BaseCommand):
 | 
			
		||||
            name="PdF",
 | 
			
		||||
            address="6 Boulevard Anatole France, 90000 Belfort",
 | 
			
		||||
        )
 | 
			
		||||
        Club.objects.create(
 | 
			
		||||
            id=settings.SITH_LAUNDERETTE_CLUB_ID,
 | 
			
		||||
            name="Laverie",
 | 
			
		||||
            address="6 Boulevard Anatole France, 90000 Belfort",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.reset_index("club")
 | 
			
		||||
        for bar_id, bar_name in settings.SITH_COUNTER_BARS:
 | 
			
		||||
@@ -291,13 +286,7 @@ class Command(BaseCommand):
 | 
			
		||||
            page=services_page,
 | 
			
		||||
            title="Services",
 | 
			
		||||
            author=skia,
 | 
			
		||||
            content="""
 | 
			
		||||
|   |   |   |
 | 
			
		||||
| :---: | :---: | :---: |
 | 
			
		||||
| [Eboutic](/eboutic) | [Laverie](/launderette) | Matmat |
 | 
			
		||||
| SAS | Weekmail | Forum|
 | 
			
		||||
 | 
			
		||||
""",
 | 
			
		||||
            content="- [Eboutic](/eboutic)\n- Matmat\n- SAS\n- Weekmail\n- Forum",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        index_page = Page(name="Index")
 | 
			
		||||
@@ -306,23 +295,10 @@ class Command(BaseCommand):
 | 
			
		||||
            page=index_page,
 | 
			
		||||
            title="Wiki index",
 | 
			
		||||
            author=root,
 | 
			
		||||
            content="""
 | 
			
		||||
Welcome to the wiki page!
 | 
			
		||||
""",
 | 
			
		||||
            content="Welcome to the wiki page!",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        laundry_page = Page(name="launderette")
 | 
			
		||||
        laundry_page.save(force_lock=True)
 | 
			
		||||
        PageRev.objects.create(
 | 
			
		||||
            page=laundry_page,
 | 
			
		||||
            title="Laverie",
 | 
			
		||||
            author=root,
 | 
			
		||||
            content="Fonctionnement de la laverie",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        groups.public.viewable_page.set(
 | 
			
		||||
            [syntax_page, services_page, index_page, laundry_page]
 | 
			
		||||
        )
 | 
			
		||||
        groups.public.viewable_page.set([syntax_page, services_page, index_page])
 | 
			
		||||
 | 
			
		||||
        self._create_subscription(root)
 | 
			
		||||
        self._create_subscription(skia)
 | 
			
		||||
@@ -868,7 +844,7 @@ Welcome to the wiki page!
 | 
			
		||||
        counter_admin.permissions.add(
 | 
			
		||||
            *list(
 | 
			
		||||
                perms.filter(
 | 
			
		||||
                    Q(content_type__app_label__in=["counter", "launderette"])
 | 
			
		||||
                    Q(content_type__app_label__in=["counter"])
 | 
			
		||||
                    & ~Q(codename__in=["delete_product", "delete_producttype"])
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
 
 | 
			
		||||
@@ -396,19 +396,10 @@ class User(AbstractUser):
 | 
			
		||||
            return self.is_root
 | 
			
		||||
        return group in self.cached_groups
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def cached_groups(self) -> list[Group]:
 | 
			
		||||
        """Get the list of groups this user is in.
 | 
			
		||||
 | 
			
		||||
        The result is cached for the default duration (should be 5 minutes)
 | 
			
		||||
 | 
			
		||||
        Returns: A list of all the groups this user is in.
 | 
			
		||||
        """
 | 
			
		||||
        groups = cache.get(f"user_{self.id}_groups")
 | 
			
		||||
        if groups is None:
 | 
			
		||||
            groups = list(self.groups.all())
 | 
			
		||||
            cache.set(f"user_{self.id}_groups", groups)
 | 
			
		||||
        return groups
 | 
			
		||||
        """Get the list of groups this user is in."""
 | 
			
		||||
        return list(self.groups.all())
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def is_root(self) -> bool:
 | 
			
		||||
@@ -421,14 +412,6 @@ class User(AbstractUser):
 | 
			
		||||
    def is_board_member(self) -> bool:
 | 
			
		||||
        return self.groups.filter(club_board=settings.SITH_MAIN_CLUB_ID).exists()
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def is_launderette_manager(self):
 | 
			
		||||
        from club.models import Club
 | 
			
		||||
 | 
			
		||||
        return Club.objects.get(
 | 
			
		||||
            id=settings.SITH_LAUNDERETTE_CLUB_ID
 | 
			
		||||
        ).get_membership_for(self)
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def is_banned_alcohol(self) -> bool:
 | 
			
		||||
        return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_ALCOHOL_ID).exists()
 | 
			
		||||
@@ -676,10 +659,6 @@ class AnonymousUser(AuthAnonymousUser):
 | 
			
		||||
    def is_board_member(self):
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def is_launderette_manager(self):
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def is_banned_alcohol(self):
 | 
			
		||||
        return False
 | 
			
		||||
 
 | 
			
		||||
@@ -1,15 +0,0 @@
 | 
			
		||||
from django.core.cache import cache
 | 
			
		||||
from django.db.models.signals import m2m_changed
 | 
			
		||||
from django.dispatch import receiver
 | 
			
		||||
 | 
			
		||||
from core.models import User
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(m2m_changed, sender=User.groups.through, dispatch_uid="user_groups_changed")
 | 
			
		||||
def user_groups_changed(sender, instance: User, **kwargs):
 | 
			
		||||
    """Clear the cached groups of the user."""
 | 
			
		||||
    # As a m2m relationship doesn't live within the model
 | 
			
		||||
    # but rather on an intermediary table, there is no
 | 
			
		||||
    # model method to override, meaning we must use
 | 
			
		||||
    # a signal to invalidate the cache when a user is removed from a group
 | 
			
		||||
    cache.delete(f"user_{instance.pk}_groups")
 | 
			
		||||
							
								
								
									
										174
									
								
								core/static/bundled/core/tooltips-index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										174
									
								
								core/static/bundled/core/tooltips-index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,174 @@
 | 
			
		||||
import {
 | 
			
		||||
  type Placement,
 | 
			
		||||
  autoPlacement,
 | 
			
		||||
  computePosition,
 | 
			
		||||
  flip,
 | 
			
		||||
  offset,
 | 
			
		||||
  size,
 | 
			
		||||
} from "@floating-ui/dom";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Library usage:
 | 
			
		||||
 *
 | 
			
		||||
 * Add a `tooltip` attribute to any html element with it's tooltip text
 | 
			
		||||
 * You can control the position of the tooltp with the `tooltip-position` attribute
 | 
			
		||||
 * Allowed placements are `auto`, `top`, `right`, `bottom`, `left`
 | 
			
		||||
 * You can add `-start` and `-end` to all allowed placement values except for `auto`
 | 
			
		||||
 * Default placement is `auto`
 | 
			
		||||
 * Note: placement are suggestions and the position could change if the popup gets
 | 
			
		||||
 *       outside of the screen.
 | 
			
		||||
 *
 | 
			
		||||
 * You can customize your tooltip by passing additional classes or ids to it
 | 
			
		||||
 * You can use `tooltip-class` and `tooltip-id` to add additional elements to the
 | 
			
		||||
 * `class` and `id` attribute of the generated tooltip
 | 
			
		||||
 *
 | 
			
		||||
 * @example
 | 
			
		||||
 * ```html
 | 
			
		||||
 * <p tooltip="tooltip text"></p>
 | 
			
		||||
 * <p tooltip="tooltip left" tooltip-position="left"></p>
 | 
			
		||||
 * <div tooltip="tooltip custom class" tooltip-class="custom custom-class"></div>
 | 
			
		||||
 * ```
 | 
			
		||||
 **/
 | 
			
		||||
 | 
			
		||||
type Status = "open" | "close";
 | 
			
		||||
 | 
			
		||||
const tooltips: Map<HTMLElement, HTMLElement> = new Map();
 | 
			
		||||
 | 
			
		||||
function getPosition(element: HTMLElement): Placement | "auto" {
 | 
			
		||||
  const position = element.getAttribute("tooltip-position");
 | 
			
		||||
  if (position) {
 | 
			
		||||
    return position as Placement | "auto";
 | 
			
		||||
  }
 | 
			
		||||
  return "auto";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getMiddleware(element: HTMLElement) {
 | 
			
		||||
  const middleware = [offset(6), size()];
 | 
			
		||||
  if (getPosition(element) === "auto") {
 | 
			
		||||
    middleware.push(autoPlacement());
 | 
			
		||||
  } else {
 | 
			
		||||
    middleware.push(flip());
 | 
			
		||||
  }
 | 
			
		||||
  return { middleware: middleware };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getPlacement(element: HTMLElement) {
 | 
			
		||||
  const position = getPosition(element);
 | 
			
		||||
  if (position !== "auto") {
 | 
			
		||||
    return { placement: position };
 | 
			
		||||
  }
 | 
			
		||||
  return {};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function createTooltip(element: HTMLElement) {
 | 
			
		||||
  const tooltip = document.createElement("div");
 | 
			
		||||
  document.body.append(tooltip);
 | 
			
		||||
  tooltips.set(element, tooltip);
 | 
			
		||||
  return tooltip;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function updateTooltip(element: HTMLElement, tooltip: HTMLElement, status: Status) {
 | 
			
		||||
  // Update tooltip status and set it's attributes and content
 | 
			
		||||
  tooltip.setAttribute("tooltip-status", status);
 | 
			
		||||
  tooltip.innerText = element.getAttribute("tooltip");
 | 
			
		||||
 | 
			
		||||
  for (const attributes of [
 | 
			
		||||
    { src: "tooltip-class", dst: "class", default: ["tooltip"] },
 | 
			
		||||
    { src: "tooltip-id", dst: "id", default: [] },
 | 
			
		||||
  ]) {
 | 
			
		||||
    const populated = attributes.default;
 | 
			
		||||
    if (element.hasAttribute(attributes.src)) {
 | 
			
		||||
      populated.push(...element.getAttribute(attributes.src).split(" "));
 | 
			
		||||
    }
 | 
			
		||||
    tooltip.setAttribute(attributes.dst, populated.join(" "));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getTooltip(element: HTMLElement) {
 | 
			
		||||
  const tooltip = tooltips.get(element);
 | 
			
		||||
  if (tooltip === undefined) {
 | 
			
		||||
    return createTooltip(element);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return tooltip;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function tooltipMouseover(event: MouseEvent) {
 | 
			
		||||
  // We get the closest tooltip to have a consistent behavior
 | 
			
		||||
  // when hovering over a child element of a tooltip marked element
 | 
			
		||||
  const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement;
 | 
			
		||||
  const tooltip = getTooltip(target);
 | 
			
		||||
  updateTooltip(target, tooltip, "open");
 | 
			
		||||
 | 
			
		||||
  computePosition(target, tooltip, {
 | 
			
		||||
    ...getPlacement(target),
 | 
			
		||||
    ...getMiddleware(target),
 | 
			
		||||
  }).then(({ x, y }) => {
 | 
			
		||||
    Object.assign(tooltip.style, {
 | 
			
		||||
      left: `${x}px`,
 | 
			
		||||
      top: `${y}px`,
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function tooltipMouseout(event: MouseEvent) {
 | 
			
		||||
  // We get the closest tooltip to have a consistent behavior
 | 
			
		||||
  // when hovering over a child element of a tooltip marked element
 | 
			
		||||
  const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement;
 | 
			
		||||
  updateTooltip(target, getTooltip(target), "close");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
window.addEventListener("DOMContentLoaded", () => {
 | 
			
		||||
  for (const el of document.querySelectorAll("[tooltip]")) {
 | 
			
		||||
    el.addEventListener("mouseover", tooltipMouseover);
 | 
			
		||||
    el.addEventListener("mouseout", tooltipMouseout);
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Add / remove callback when tooltip attribute is added / removed
 | 
			
		||||
new MutationObserver((mutations: MutationRecord[]) => {
 | 
			
		||||
  for (const mutation of mutations) {
 | 
			
		||||
    const target = mutation.target as HTMLElement;
 | 
			
		||||
    target.removeEventListener("mouseover", tooltipMouseover);
 | 
			
		||||
    target.removeEventListener("mouseout", tooltipMouseout);
 | 
			
		||||
    if (target.hasAttribute("tooltip")) {
 | 
			
		||||
      target.addEventListener("mouseover", tooltipMouseover);
 | 
			
		||||
      target.addEventListener("mouseout", tooltipMouseout);
 | 
			
		||||
      if (target.matches(":hover")) {
 | 
			
		||||
        target.dispatchEvent(new Event("mouseover", { bubbles: true }));
 | 
			
		||||
      } else {
 | 
			
		||||
        target.dispatchEvent(new Event("mouseout", { bubbles: true }));
 | 
			
		||||
      }
 | 
			
		||||
    } else if (tooltips.has(target)) {
 | 
			
		||||
      // Remove corresponding tooltip
 | 
			
		||||
      tooltips.get(target).remove();
 | 
			
		||||
      tooltips.delete(target);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}).observe(document.body, {
 | 
			
		||||
  attributes: true,
 | 
			
		||||
  attributeFilter: ["tooltip", "tooltip-class", "toolitp-position", "tooltip-id"],
 | 
			
		||||
  subtree: true,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Remove orphan tooltips
 | 
			
		||||
new MutationObserver((mutations: MutationRecord[]) => {
 | 
			
		||||
  for (const mutation of mutations) {
 | 
			
		||||
    for (const node of mutation.removedNodes) {
 | 
			
		||||
      if (node.nodeType !== node.ELEMENT_NODE) {
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
      const target = node as HTMLElement;
 | 
			
		||||
      if (!target.hasAttribute("tooltip")) {
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
      if (tooltips.has(target)) {
 | 
			
		||||
        tooltips.get(target).remove();
 | 
			
		||||
        tooltips.delete(target);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}).observe(document.body, {
 | 
			
		||||
  subtree: true,
 | 
			
		||||
  childList: true,
 | 
			
		||||
});
 | 
			
		||||
@@ -45,63 +45,6 @@ body {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[tooltip] {
 | 
			
		||||
  position: relative;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[tooltip]::before {
 | 
			
		||||
  @include shadow;
 | 
			
		||||
  z-index: 1;
 | 
			
		||||
  pointer-events: none;
 | 
			
		||||
  content: attr(tooltip);
 | 
			
		||||
  left: 50%;
 | 
			
		||||
  transform: translateX(-50%);
 | 
			
		||||
  background-color: #333;
 | 
			
		||||
  color: #fff;
 | 
			
		||||
  border: 0.5px solid hsl(0, 0%, 50%);
 | 
			
		||||
  border-radius: 5px;
 | 
			
		||||
  padding: 5px 10px;
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  white-space: nowrap;
 | 
			
		||||
  opacity: 0;
 | 
			
		||||
  transition: opacity 500ms ease-out;
 | 
			
		||||
  top: 120%; // Put the tooltip under the element
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[tooltip]:hover::before {
 | 
			
		||||
  opacity: 1;
 | 
			
		||||
  transition: opacity 500ms ease-in;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[no-hover][tooltip]::before {
 | 
			
		||||
  opacity: 1;
 | 
			
		||||
  transition: opacity 500ms ease-in;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[position="top"][tooltip]::before {
 | 
			
		||||
  top: initial;
 | 
			
		||||
  bottom: 120%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[position="bottom"][tooltip]::before {
 | 
			
		||||
  top: 120%;
 | 
			
		||||
  bottom: initial;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[position="left"][tooltip]::before {
 | 
			
		||||
  top: initial;
 | 
			
		||||
  bottom: 0%;
 | 
			
		||||
  left: initial;
 | 
			
		||||
  right: 65%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[position="right"][tooltip]::before {
 | 
			
		||||
  top: initial;
 | 
			
		||||
  bottom: 0%;
 | 
			
		||||
  left: 150%;
 | 
			
		||||
  right: initial;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.ib {
 | 
			
		||||
  display: inline-block;
 | 
			
		||||
  padding: 1px;
 | 
			
		||||
@@ -822,12 +765,6 @@ textarea {
 | 
			
		||||
  margin-top: 10px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*---------------------------LAUNDERETTE-------------------------------*/
 | 
			
		||||
 | 
			
		||||
#token_form label {
 | 
			
		||||
  display: inline;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*--------------------------------FOOTER-------------------------------*/
 | 
			
		||||
 | 
			
		||||
footer {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										26
									
								
								core/static/core/tooltips.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								core/static/core/tooltips.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
			
		||||
@import "colors";
 | 
			
		||||
 | 
			
		||||
.tooltip {
 | 
			
		||||
  @include shadow;
 | 
			
		||||
  z-index: 1;
 | 
			
		||||
  pointer-events: none;
 | 
			
		||||
  background-color: #333;
 | 
			
		||||
  color: #fff;
 | 
			
		||||
  border: 0.5px solid hsl(0, 0%, 50%);
 | 
			
		||||
  border-radius: 5px;
 | 
			
		||||
  padding: 5px 10px;
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  white-space: nowrap;
 | 
			
		||||
  opacity: 0;
 | 
			
		||||
  transition: opacity 500ms ease-out;
 | 
			
		||||
 | 
			
		||||
  white-space: normal;
 | 
			
		||||
 | 
			
		||||
  left: 0;
 | 
			
		||||
  top: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tooltip[tooltip-status=open] {
 | 
			
		||||
  opacity: 1;
 | 
			
		||||
  transition: opacity 500ms ease-in;
 | 
			
		||||
}
 | 
			
		||||
@@ -24,13 +24,21 @@ body {
 | 
			
		||||
    background-color: white;
 | 
			
		||||
    margin: 0;
 | 
			
		||||
 | 
			
		||||
    > .title {
 | 
			
		||||
    .alert {
 | 
			
		||||
      word-wrap: break-word;
 | 
			
		||||
      white-space: normal;
 | 
			
		||||
      text-align: center;
 | 
			
		||||
      display: block;
 | 
			
		||||
      width: fit-content;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    >.title {
 | 
			
		||||
      text-align: center;
 | 
			
		||||
      margin: 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    > div,
 | 
			
		||||
    > form {
 | 
			
		||||
    >div,
 | 
			
		||||
    >form {
 | 
			
		||||
      box-sizing: border-box;
 | 
			
		||||
      display: flex;
 | 
			
		||||
      flex-direction: column;
 | 
			
		||||
@@ -41,8 +49,8 @@ body {
 | 
			
		||||
      max-width: 500px;
 | 
			
		||||
      margin-top: 20px;
 | 
			
		||||
 | 
			
		||||
      > p,
 | 
			
		||||
      > div {
 | 
			
		||||
      >p,
 | 
			
		||||
      >div {
 | 
			
		||||
        display: flex;
 | 
			
		||||
        flex-direction: column;
 | 
			
		||||
        justify-content: center;
 | 
			
		||||
@@ -50,7 +58,7 @@ body {
 | 
			
		||||
        width: 100%;
 | 
			
		||||
        margin: 0;
 | 
			
		||||
 | 
			
		||||
        > label {
 | 
			
		||||
        >label {
 | 
			
		||||
          width: 100%;
 | 
			
		||||
 | 
			
		||||
          @media (min-width: 500px) {
 | 
			
		||||
@@ -59,9 +67,9 @@ body {
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      > input,
 | 
			
		||||
      > p > input,
 | 
			
		||||
      > div > input {
 | 
			
		||||
      >input,
 | 
			
		||||
      >p>input,
 | 
			
		||||
      >div>input {
 | 
			
		||||
        box-sizing: border-box;
 | 
			
		||||
        width: 100%;
 | 
			
		||||
        max-width: 500px;
 | 
			
		||||
@@ -71,35 +79,35 @@ body {
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      > .errorlist {
 | 
			
		||||
      >.errorlist {
 | 
			
		||||
        color: red;
 | 
			
		||||
        text-align: center;
 | 
			
		||||
        margin: 10px 0 0 0;
 | 
			
		||||
        list-style-type: none;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      > .required > .helptext {
 | 
			
		||||
      >.required>.helptext {
 | 
			
		||||
        text-align: center;
 | 
			
		||||
        font-style: italic;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      > .required:last-of-type {
 | 
			
		||||
      >.required:last-of-type {
 | 
			
		||||
        box-sizing: border-box;
 | 
			
		||||
        max-width: 300px;
 | 
			
		||||
        flex-direction: row;
 | 
			
		||||
        flex-wrap: wrap;
 | 
			
		||||
        justify-content: space-between;
 | 
			
		||||
 | 
			
		||||
        > label {
 | 
			
		||||
        >label {
 | 
			
		||||
          width: 100%;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        > img {
 | 
			
		||||
        >img {
 | 
			
		||||
          width: 70px;
 | 
			
		||||
          object-fit: contain;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        > input {
 | 
			
		||||
        >input {
 | 
			
		||||
          width: 200px;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,7 @@
 | 
			
		||||
      <link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
 | 
			
		||||
      <link rel="stylesheet" href="{{ static('core/base.css') }}">
 | 
			
		||||
      <link rel="stylesheet" href="{{ static('core/style.scss') }}">
 | 
			
		||||
      <link rel="stylesheet" href="{{ static('core/tooltips.scss') }}">
 | 
			
		||||
      <link rel="stylesheet" href="{{ static('core/markdown.scss') }}">
 | 
			
		||||
      <link rel="stylesheet" href="{{ static('core/header.scss') }}">
 | 
			
		||||
      <link rel="stylesheet" href="{{ static('core/navbar.scss') }}">
 | 
			
		||||
@@ -24,6 +25,7 @@
 | 
			
		||||
      <script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
 | 
			
		||||
      <script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
 | 
			
		||||
      <script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script>
 | 
			
		||||
      <script type="module" src="{{ static('bundled/core/tooltips-index.ts') }}"></script>
 | 
			
		||||
 | 
			
		||||
      <!-- Jquery declared here to be accessible in every django widgets -->
 | 
			
		||||
      <script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
 | 
			
		||||
 
 | 
			
		||||
@@ -24,7 +24,6 @@
 | 
			
		||||
      <span class="head">{% trans %}Services{% endtrans %}</span>
 | 
			
		||||
      <ul class="content">
 | 
			
		||||
        <li><a href="{{ url('matmat:search_clear') }}">{% trans %}Matmatronch{% endtrans %}</a></li>
 | 
			
		||||
        <li><a href="/launderette">{% trans %}Launderette{% endtrans %}</a></li>
 | 
			
		||||
        <li><a href="{{ url('core:file_list') }}">{% trans %}Files{% endtrans %}</a></li>
 | 
			
		||||
        <li><a href="{{ url('pedagogy:guide') }}">{% trans %}Pedagogy{% endtrans %}</a></li>
 | 
			
		||||
      </ul>
 | 
			
		||||
 
 | 
			
		||||
@@ -29,14 +29,13 @@
 | 
			
		||||
  <form method="post" action="{{ url('core:login') }}">
 | 
			
		||||
    {% if form.errors %}
 | 
			
		||||
      <p class="alert alert-red">{% trans %}Your username and password didn't match. Please try again.{% endtrans %}</p>
 | 
			
		||||
      <br>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
 | 
			
		||||
    {% csrf_token %}
 | 
			
		||||
 | 
			
		||||
    <div>
 | 
			
		||||
      <label for="{{ form.username.name }}">{{ form.username.label }}</label>
 | 
			
		||||
      <input id="id_username" maxlength="254" name="username" type="text" autofocus="autofocus" />
 | 
			
		||||
      {{ form.username }}
 | 
			
		||||
      {{ form.username.errors }}
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -86,19 +86,6 @@
 | 
			
		||||
  {% trans %}Account number: {% endtrans %}{{ user.customer.account_id }}<br/>
 | 
			
		||||
{%- endmacro %}
 | 
			
		||||
 | 
			
		||||
{% macro show_slots(user) %}
 | 
			
		||||
  {% if user.slots.filter(start_date__gte=timezone.now()).exists() %}
 | 
			
		||||
    <h5>{% trans %}Slot{% endtrans %}</h5>
 | 
			
		||||
    <ul>
 | 
			
		||||
      {% for i in user.slots.filter(start_date__gte=timezone.now().replace(tzinfo=None)).all() %}
 | 
			
		||||
        <li>{{ i.get_type_display() }} - {{i.machine.launderette }}, {{ i.start_date|date("l j") }} :
 | 
			
		||||
          {{ i.start_date|time(DATETIME_FORMAT) }} |
 | 
			
		||||
          <a href="{{ url('launderette:delete_slot', slot_id=i.id) }}">{% trans %}Delete{% endtrans %}</a></li>
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
    </ul>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
{% endmacro %}
 | 
			
		||||
 | 
			
		||||
{% macro show_tokens(user) %}
 | 
			
		||||
  {% if user.tokens.exists() %}
 | 
			
		||||
    <h5>{% trans %}Tokens{% endtrans %}</h5>
 | 
			
		||||
 
 | 
			
		||||
@@ -18,7 +18,7 @@
 | 
			
		||||
  <form action="{{ url('core:register') }}" method="post">
 | 
			
		||||
    {% csrf_token %}
 | 
			
		||||
    {% render_honeypot_field %}
 | 
			
		||||
    {{ form }}
 | 
			
		||||
    {{ form.as_p() }}
 | 
			
		||||
    <input type="submit" value="{% trans %}Register{% endtrans %}" />
 | 
			
		||||
  </form>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 
 | 
			
		||||
@@ -141,12 +141,6 @@
 | 
			
		||||
            {{ user_subscription(profile) }}
 | 
			
		||||
          </div>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        {% if user == profile or user.is_root or user.is_board_member or user.is_launderette_manager %}
 | 
			
		||||
          <div>
 | 
			
		||||
            {{ show_tokens(profile) }}
 | 
			
		||||
            {{ show_slots(profile) }}
 | 
			
		||||
          </div>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
      {% else %}
 | 
			
		||||
        <div>
 | 
			
		||||
          {% trans %}Not subscribed{% endtrans %}
 | 
			
		||||
 
 | 
			
		||||
@@ -411,10 +411,11 @@ class TestUserIsInGroup(TestCase):
 | 
			
		||||
        """Test that the number of db queries is stable
 | 
			
		||||
        and that less queries are made when making a new call.
 | 
			
		||||
        """
 | 
			
		||||
        # make sure Skia is in at least one group
 | 
			
		||||
        group_in = baker.make(Group)
 | 
			
		||||
        self.public_user.groups.add(group_in)
 | 
			
		||||
 | 
			
		||||
        # clear the cached property `User.cached_groups`
 | 
			
		||||
        self.public_user.__dict__.pop("cached_groups", None)
 | 
			
		||||
        cache.clear()
 | 
			
		||||
        # Test when the user is in the group
 | 
			
		||||
        with self.assertNumQueries(2):
 | 
			
		||||
@@ -423,6 +424,7 @@ class TestUserIsInGroup(TestCase):
 | 
			
		||||
            self.public_user.is_in_group(pk=group_in.id)
 | 
			
		||||
 | 
			
		||||
        group_not_in = baker.make(Group)
 | 
			
		||||
        self.public_user.__dict__.pop("cached_groups", None)
 | 
			
		||||
        cache.clear()
 | 
			
		||||
        # Test when the user is not in the group
 | 
			
		||||
        with self.assertNumQueries(2):
 | 
			
		||||
@@ -447,24 +449,6 @@ class TestUserIsInGroup(TestCase):
 | 
			
		||||
        )
 | 
			
		||||
        assert cached_membership == "not_member"
 | 
			
		||||
 | 
			
		||||
    def test_cache_properly_cleared_group(self):
 | 
			
		||||
        """Test that when a user is removed from a group,
 | 
			
		||||
        the is_in_group_method return False when calling it again.
 | 
			
		||||
        """
 | 
			
		||||
        # testing with pk
 | 
			
		||||
        self.public_user.groups.add(self.com_admin.pk)
 | 
			
		||||
        assert self.public_user.is_in_group(pk=self.com_admin.pk) is True
 | 
			
		||||
 | 
			
		||||
        self.public_user.groups.remove(self.com_admin.pk)
 | 
			
		||||
        assert self.public_user.is_in_group(pk=self.com_admin.pk) is False
 | 
			
		||||
 | 
			
		||||
        # testing with name
 | 
			
		||||
        self.public_user.groups.add(self.sas_admin.pk)
 | 
			
		||||
        assert self.public_user.is_in_group(name="SAS admin") is True
 | 
			
		||||
 | 
			
		||||
        self.public_user.groups.remove(self.sas_admin.pk)
 | 
			
		||||
        assert self.public_user.is_in_group(name="SAS admin") is False
 | 
			
		||||
 | 
			
		||||
    def test_not_existing_group(self):
 | 
			
		||||
        """Test that searching for a not existing group
 | 
			
		||||
        returns False.
 | 
			
		||||
 
 | 
			
		||||
@@ -318,3 +318,20 @@ def test_displayed_other_user_tabs(user_factory, expected_tabs: list[str]):
 | 
			
		||||
    view.object = subscriber_user.make()  # user whose page is being seen
 | 
			
		||||
    tabs = [tab["slug"] for tab in view.get_list_of_tabs()]
 | 
			
		||||
    assert tabs == expected_tabs
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.django_db
 | 
			
		||||
class TestRedirectMe:
 | 
			
		||||
    @pytest.mark.parametrize(
 | 
			
		||||
        "route", ["core:user_profile", "core:user_account", "core:user_edit"]
 | 
			
		||||
    )
 | 
			
		||||
    def test_redirect(self, client: Client, route: str):
 | 
			
		||||
        user = subscriber_user.make()
 | 
			
		||||
        client.force_login(user)
 | 
			
		||||
        target_url = reverse(route, kwargs={"user_id": user.id})
 | 
			
		||||
        src_url = target_url.replace(str(user.id), "me")
 | 
			
		||||
        assertRedirects(client.get(src_url), target_url)
 | 
			
		||||
 | 
			
		||||
    def test_anonymous_user(self, client: Client):
 | 
			
		||||
        url = reverse("core:user_me_redirect")
 | 
			
		||||
        assertRedirects(client.get(url), reverse("core:login", query={"next": url}))
 | 
			
		||||
 
 | 
			
		||||
@@ -21,7 +21,6 @@
 | 
			
		||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
 | 
			
		||||
#
 | 
			
		||||
#
 | 
			
		||||
 | 
			
		||||
from django.urls import path, re_path, register_converter
 | 
			
		||||
from django.views.generic import RedirectView
 | 
			
		||||
 | 
			
		||||
@@ -68,6 +67,7 @@ from core.views import (
 | 
			
		||||
    UserGodfathersTreeView,
 | 
			
		||||
    UserGodfathersView,
 | 
			
		||||
    UserListView,
 | 
			
		||||
    UserMeRedirect,
 | 
			
		||||
    UserMiniView,
 | 
			
		||||
    UserPreferencesView,
 | 
			
		||||
    UserStatsView,
 | 
			
		||||
@@ -141,6 +141,12 @@ urlpatterns = [
 | 
			
		||||
    ),
 | 
			
		||||
    # User views
 | 
			
		||||
    path("user/", UserListView.as_view(), name="user_list"),
 | 
			
		||||
    path(
 | 
			
		||||
        "user/me/<path:remaining_path>/",
 | 
			
		||||
        UserMeRedirect.as_view(),
 | 
			
		||||
        name="user_me_redirect_with_path",
 | 
			
		||||
    ),
 | 
			
		||||
    path("user/me/", UserMeRedirect.as_view(), name="user_me_redirect"),
 | 
			
		||||
    path("user/<int:user_id>/mini/", UserMiniView.as_view(), name="user_profile_mini"),
 | 
			
		||||
    path("user/<int:user_id>/", UserView.as_view(), name="user_profile"),
 | 
			
		||||
    path(
 | 
			
		||||
 
 | 
			
		||||
@@ -48,6 +48,7 @@ from django.views.generic import (
 | 
			
		||||
    DeleteView,
 | 
			
		||||
    DetailView,
 | 
			
		||||
    ListView,
 | 
			
		||||
    RedirectView,
 | 
			
		||||
    TemplateView,
 | 
			
		||||
)
 | 
			
		||||
from django.views.generic.dates import MonthMixin, YearMixin
 | 
			
		||||
@@ -182,6 +183,13 @@ class UserCreationView(FormView):
 | 
			
		||||
        return super().form_valid(form)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserMeRedirect(LoginRequiredMixin, RedirectView):
 | 
			
		||||
    def get_redirect_url(self, *args, **kwargs):
 | 
			
		||||
        if remaining := kwargs.get("remaining_path"):
 | 
			
		||||
            return f"/user/{self.request.user.id}/{remaining}/"
 | 
			
		||||
        return f"/user/{self.request.user.id}/"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserTabsMixin(TabedViewMixin):
 | 
			
		||||
    def get_tabs_title(self):
 | 
			
		||||
        return self.object.get_display_name()
 | 
			
		||||
@@ -564,10 +572,8 @@ class UserToolsView(LoginRequiredMixin, QuickNotifMixin, UserTabsMixin, Template
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        self.object = self.request.user
 | 
			
		||||
        from launderette.models import Launderette
 | 
			
		||||
 | 
			
		||||
        kwargs = super().get_context_data(**kwargs)
 | 
			
		||||
        kwargs["launderettes"] = Launderette.objects.all()
 | 
			
		||||
        kwargs["profile"] = self.request.user
 | 
			
		||||
        kwargs["object"] = self.request.user
 | 
			
		||||
        return kwargs
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										112
									
								
								counter/forms.py
									
									
									
									
									
								
							
							
						
						
									
										112
									
								
								counter/forms.py
									
									
									
									
									
								
							@@ -1,4 +1,7 @@
 | 
			
		||||
import math
 | 
			
		||||
 | 
			
		||||
from django import forms
 | 
			
		||||
from django.db.models import Q
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from phonenumber_field.widgets import RegionalPhoneNumberWidget
 | 
			
		||||
 | 
			
		||||
@@ -261,3 +264,112 @@ class CloseCustomerAccountForm(forms.Form):
 | 
			
		||||
        widget=AutoCompleteSelectUser,
 | 
			
		||||
        queryset=User.objects.all(),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ProductForm(forms.Form):
 | 
			
		||||
    quantity = forms.IntegerField(min_value=1, required=True)
 | 
			
		||||
    id = forms.IntegerField(min_value=0, required=True)
 | 
			
		||||
 | 
			
		||||
    def __init__(
 | 
			
		||||
        self,
 | 
			
		||||
        customer: Customer,
 | 
			
		||||
        counter: Counter,
 | 
			
		||||
        allowed_products: dict[int, Product],
 | 
			
		||||
        *args,
 | 
			
		||||
        **kwargs,
 | 
			
		||||
    ):
 | 
			
		||||
        self.customer = customer  # Used by formset
 | 
			
		||||
        self.counter = counter  # Used by formset
 | 
			
		||||
        self.allowed_products = allowed_products
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def clean_id(self):
 | 
			
		||||
        data = self.cleaned_data["id"]
 | 
			
		||||
 | 
			
		||||
        # We store self.product so we can use it later on the formset validation
 | 
			
		||||
        # And also in the global clean
 | 
			
		||||
        self.product = self.allowed_products.get(data, None)
 | 
			
		||||
        if self.product is None:
 | 
			
		||||
            raise forms.ValidationError(
 | 
			
		||||
                _("The selected product isn't available for this user")
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        return data
 | 
			
		||||
 | 
			
		||||
    def clean(self):
 | 
			
		||||
        cleaned_data = super().clean()
 | 
			
		||||
        if len(self.errors) > 0:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        # Compute prices
 | 
			
		||||
        cleaned_data["bonus_quantity"] = 0
 | 
			
		||||
        if self.product.tray:
 | 
			
		||||
            cleaned_data["bonus_quantity"] = math.floor(
 | 
			
		||||
                cleaned_data["quantity"] / Product.QUANTITY_FOR_TRAY_PRICE
 | 
			
		||||
            )
 | 
			
		||||
        cleaned_data["total_price"] = self.product.price * (
 | 
			
		||||
            cleaned_data["quantity"] - cleaned_data["bonus_quantity"]
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        return cleaned_data
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BaseBasketForm(forms.BaseFormSet):
 | 
			
		||||
    def clean(self):
 | 
			
		||||
        self.forms = [form for form in self.forms if form.cleaned_data != {}]
 | 
			
		||||
 | 
			
		||||
        if len(self.forms) == 0:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        self._check_forms_have_errors()
 | 
			
		||||
        self._check_product_are_unique()
 | 
			
		||||
        self._check_recorded_products(self[0].customer)
 | 
			
		||||
        self._check_enough_money(self[0].counter, self[0].customer)
 | 
			
		||||
 | 
			
		||||
    def _check_forms_have_errors(self):
 | 
			
		||||
        if any(len(form.errors) > 0 for form in self):
 | 
			
		||||
            raise forms.ValidationError(_("Submitted basket is invalid"))
 | 
			
		||||
 | 
			
		||||
    def _check_product_are_unique(self):
 | 
			
		||||
        product_ids = {form.cleaned_data["id"] for form in self.forms}
 | 
			
		||||
        if len(product_ids) != len(self.forms):
 | 
			
		||||
            raise forms.ValidationError(_("Duplicated product entries."))
 | 
			
		||||
 | 
			
		||||
    def _check_enough_money(self, counter: Counter, customer: Customer):
 | 
			
		||||
        self.total_price = sum([data["total_price"] for data in self.cleaned_data])
 | 
			
		||||
        if self.total_price > customer.amount:
 | 
			
		||||
            raise forms.ValidationError(_("Not enough money"))
 | 
			
		||||
 | 
			
		||||
    def _check_recorded_products(self, customer: Customer):
 | 
			
		||||
        """Check for, among other things, ecocups and pitchers"""
 | 
			
		||||
        items = {
 | 
			
		||||
            form.cleaned_data["id"]: form.cleaned_data["quantity"]
 | 
			
		||||
            for form in self.forms
 | 
			
		||||
        }
 | 
			
		||||
        ids = list(items.keys())
 | 
			
		||||
        returnables = list(
 | 
			
		||||
            ReturnableProduct.objects.filter(
 | 
			
		||||
                Q(product_id__in=ids) | Q(returned_product_id__in=ids)
 | 
			
		||||
            ).annotate_balance_for(customer)
 | 
			
		||||
        )
 | 
			
		||||
        limit_reached = []
 | 
			
		||||
        for returnable in returnables:
 | 
			
		||||
            returnable.balance += items.get(returnable.product_id, 0)
 | 
			
		||||
        for returnable in returnables:
 | 
			
		||||
            dcons = items.get(returnable.returned_product_id, 0)
 | 
			
		||||
            returnable.balance -= dcons
 | 
			
		||||
            if dcons and returnable.balance < -returnable.max_return:
 | 
			
		||||
                limit_reached.append(returnable.returned_product)
 | 
			
		||||
        if limit_reached:
 | 
			
		||||
            raise forms.ValidationError(
 | 
			
		||||
                _(
 | 
			
		||||
                    "This user have reached his recording limit "
 | 
			
		||||
                    "for the following products : %s"
 | 
			
		||||
                )
 | 
			
		||||
                % ", ".join([str(p) for p in limit_reached])
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
BasketForm = forms.formset_factory(
 | 
			
		||||
    ProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
 | 
			
		||||
)
 | 
			
		||||
 
 | 
			
		||||
@@ -47,6 +47,10 @@ from counter.fields import CurrencyField
 | 
			
		||||
from subscription.models import Subscription
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_eboutic() -> Counter:
 | 
			
		||||
    return Counter.objects.filter(type="EBOUTIC").order_by("id").first()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CustomerQuerySet(models.QuerySet):
 | 
			
		||||
    def update_amount(self) -> int:
 | 
			
		||||
        """Update the amount of all customers selected by this queryset.
 | 
			
		||||
 
 | 
			
		||||
@@ -46,6 +46,15 @@ from counter.models import (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def set_age(user: User, age: int):
 | 
			
		||||
    user.date_of_birth = localdate().replace(year=localdate().year - age)
 | 
			
		||||
    user.save()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def force_refill_user(user: User, amount: Decimal | int):
 | 
			
		||||
    baker.make(Refilling, amount=amount, customer=user.customer, is_validated=False)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestFullClickBase(TestCase):
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def setUpTestData(cls):
 | 
			
		||||
@@ -226,11 +235,11 @@ class TestCounterClick(TestFullClickBase):
 | 
			
		||||
        cls.banned_counter_customer = subscriber_user.make()
 | 
			
		||||
        cls.banned_alcohol_customer = subscriber_user.make()
 | 
			
		||||
 | 
			
		||||
        cls.set_age(cls.customer, 20)
 | 
			
		||||
        cls.set_age(cls.barmen, 20)
 | 
			
		||||
        cls.set_age(cls.club_admin, 20)
 | 
			
		||||
        cls.set_age(cls.banned_alcohol_customer, 20)
 | 
			
		||||
        cls.set_age(cls.underage_customer, 17)
 | 
			
		||||
        set_age(cls.customer, 20)
 | 
			
		||||
        set_age(cls.barmen, 20)
 | 
			
		||||
        set_age(cls.club_admin, 20)
 | 
			
		||||
        set_age(cls.banned_alcohol_customer, 20)
 | 
			
		||||
        set_age(cls.underage_customer, 17)
 | 
			
		||||
 | 
			
		||||
        cls.banned_alcohol_customer.ban_groups.add(
 | 
			
		||||
            BanGroup.objects.get(pk=settings.SITH_GROUP_BANNED_ALCOHOL_ID)
 | 
			
		||||
@@ -278,11 +287,6 @@ class TestCounterClick(TestFullClickBase):
 | 
			
		||||
            {"username": used_barman.username, "password": "plop"},
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def set_age(cls, user: User, age: int):
 | 
			
		||||
        user.date_of_birth = localdate().replace(year=localdate().year - age)
 | 
			
		||||
        user.save()
 | 
			
		||||
 | 
			
		||||
    def submit_basket(
 | 
			
		||||
        self,
 | 
			
		||||
        user: User,
 | 
			
		||||
@@ -306,9 +310,6 @@ class TestCounterClick(TestFullClickBase):
 | 
			
		||||
            data,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def refill_user(self, user: User, amount: Decimal | int):
 | 
			
		||||
        baker.make(Refilling, amount=amount, customer=user.customer, is_validated=False)
 | 
			
		||||
 | 
			
		||||
    def test_click_eboutic_failure(self):
 | 
			
		||||
        eboutic = baker.make(Counter, type="EBOUTIC")
 | 
			
		||||
        self.client.force_login(self.club_admin)
 | 
			
		||||
@@ -318,7 +319,7 @@ class TestCounterClick(TestFullClickBase):
 | 
			
		||||
        assert res.status_code == 404
 | 
			
		||||
 | 
			
		||||
    def test_click_office_success(self):
 | 
			
		||||
        self.refill_user(self.customer, 10)
 | 
			
		||||
        force_refill_user(self.customer, 10)
 | 
			
		||||
        self.client.force_login(self.club_admin)
 | 
			
		||||
        res = self.submit_basket(
 | 
			
		||||
            self.customer, [BasketItem(self.stamps.id, 5)], counter=self.club_counter
 | 
			
		||||
@@ -327,7 +328,7 @@ class TestCounterClick(TestFullClickBase):
 | 
			
		||||
        assert self.updated_amount(self.customer) == Decimal("2.5")
 | 
			
		||||
 | 
			
		||||
        # Test no special price on office counter
 | 
			
		||||
        self.refill_user(self.club_admin, 10)
 | 
			
		||||
        force_refill_user(self.club_admin, 10)
 | 
			
		||||
        res = self.submit_basket(
 | 
			
		||||
            self.club_admin, [BasketItem(self.stamps.id, 1)], counter=self.club_counter
 | 
			
		||||
        )
 | 
			
		||||
@@ -336,7 +337,7 @@ class TestCounterClick(TestFullClickBase):
 | 
			
		||||
        assert self.updated_amount(self.club_admin) == Decimal("8.5")
 | 
			
		||||
 | 
			
		||||
    def test_click_bar_success(self):
 | 
			
		||||
        self.refill_user(self.customer, 10)
 | 
			
		||||
        force_refill_user(self.customer, 10)
 | 
			
		||||
        self.login_in_bar(self.barmen)
 | 
			
		||||
        res = self.submit_basket(
 | 
			
		||||
            self.customer, [BasketItem(self.beer.id, 2), BasketItem(self.snack.id, 1)]
 | 
			
		||||
@@ -347,7 +348,7 @@ class TestCounterClick(TestFullClickBase):
 | 
			
		||||
 | 
			
		||||
        # Test barmen special price
 | 
			
		||||
 | 
			
		||||
        self.refill_user(self.barmen, 10)
 | 
			
		||||
        force_refill_user(self.barmen, 10)
 | 
			
		||||
 | 
			
		||||
        assert (
 | 
			
		||||
            self.submit_basket(self.barmen, [BasketItem(self.beer.id, 1)])
 | 
			
		||||
@@ -356,7 +357,7 @@ class TestCounterClick(TestFullClickBase):
 | 
			
		||||
        assert self.updated_amount(self.barmen) == Decimal("9")
 | 
			
		||||
 | 
			
		||||
    def test_click_tray_price(self):
 | 
			
		||||
        self.refill_user(self.customer, 20)
 | 
			
		||||
        force_refill_user(self.customer, 20)
 | 
			
		||||
        self.login_in_bar(self.barmen)
 | 
			
		||||
 | 
			
		||||
        # Not applying tray price
 | 
			
		||||
@@ -373,7 +374,7 @@ class TestCounterClick(TestFullClickBase):
 | 
			
		||||
        self.login_in_bar()
 | 
			
		||||
 | 
			
		||||
        for user in [self.underage_customer, self.banned_alcohol_customer]:
 | 
			
		||||
            self.refill_user(user, 10)
 | 
			
		||||
            force_refill_user(user, 10)
 | 
			
		||||
 | 
			
		||||
            # Buy product without age limit
 | 
			
		||||
            res = self.submit_basket(user, [BasketItem(self.snack.id, 2)])
 | 
			
		||||
@@ -394,7 +395,7 @@ class TestCounterClick(TestFullClickBase):
 | 
			
		||||
            self.banned_counter_customer,
 | 
			
		||||
            self.customer_old_can_not_buy,
 | 
			
		||||
        ]:
 | 
			
		||||
            self.refill_user(user, 10)
 | 
			
		||||
            force_refill_user(user, 10)
 | 
			
		||||
            resp = self.submit_basket(user, [BasketItem(self.snack.id, 2)])
 | 
			
		||||
            assert resp.status_code == 302
 | 
			
		||||
            assert resp.url == resolve_url(self.counter)
 | 
			
		||||
@@ -410,7 +411,7 @@ class TestCounterClick(TestFullClickBase):
 | 
			
		||||
 | 
			
		||||
    def test_click_allowed_old_subscriber(self):
 | 
			
		||||
        self.login_in_bar()
 | 
			
		||||
        self.refill_user(self.customer_old_can_buy, 10)
 | 
			
		||||
        force_refill_user(self.customer_old_can_buy, 10)
 | 
			
		||||
        res = self.submit_basket(
 | 
			
		||||
            self.customer_old_can_buy, [BasketItem(self.snack.id, 2)]
 | 
			
		||||
        )
 | 
			
		||||
@@ -420,7 +421,7 @@ class TestCounterClick(TestFullClickBase):
 | 
			
		||||
 | 
			
		||||
    def test_click_wrong_counter(self):
 | 
			
		||||
        self.login_in_bar()
 | 
			
		||||
        self.refill_user(self.customer, 10)
 | 
			
		||||
        force_refill_user(self.customer, 10)
 | 
			
		||||
        res = self.submit_basket(
 | 
			
		||||
            self.customer, [BasketItem(self.snack.id, 2)], counter=self.other_counter
 | 
			
		||||
        )
 | 
			
		||||
@@ -444,7 +445,7 @@ class TestCounterClick(TestFullClickBase):
 | 
			
		||||
        assert self.updated_amount(self.customer) == Decimal("10")
 | 
			
		||||
 | 
			
		||||
    def test_click_not_connected(self):
 | 
			
		||||
        self.refill_user(self.customer, 10)
 | 
			
		||||
        force_refill_user(self.customer, 10)
 | 
			
		||||
        res = self.submit_basket(self.customer, [BasketItem(self.snack.id, 2)])
 | 
			
		||||
        assertRedirects(res, self.counter.get_absolute_url())
 | 
			
		||||
 | 
			
		||||
@@ -456,15 +457,29 @@ class TestCounterClick(TestFullClickBase):
 | 
			
		||||
        assert self.updated_amount(self.customer) == Decimal("10")
 | 
			
		||||
 | 
			
		||||
    def test_click_product_not_in_counter(self):
 | 
			
		||||
        self.refill_user(self.customer, 10)
 | 
			
		||||
        force_refill_user(self.customer, 10)
 | 
			
		||||
        self.login_in_bar()
 | 
			
		||||
 | 
			
		||||
        res = self.submit_basket(self.customer, [BasketItem(self.stamps.id, 2)])
 | 
			
		||||
        assert res.status_code == 200
 | 
			
		||||
        assert self.updated_amount(self.customer) == Decimal("10")
 | 
			
		||||
 | 
			
		||||
    def test_basket_empty(self):
 | 
			
		||||
        force_refill_user(self.customer, 10)
 | 
			
		||||
 | 
			
		||||
        for basket in [
 | 
			
		||||
            [],
 | 
			
		||||
            [BasketItem(None, None)],
 | 
			
		||||
            [BasketItem(None, None), BasketItem(None, None)],
 | 
			
		||||
        ]:
 | 
			
		||||
            assertRedirects(
 | 
			
		||||
                self.submit_basket(self.customer, basket),
 | 
			
		||||
                self.counter.get_absolute_url(),
 | 
			
		||||
            )
 | 
			
		||||
            assert self.updated_amount(self.customer) == Decimal("10")
 | 
			
		||||
 | 
			
		||||
    def test_click_product_invalid(self):
 | 
			
		||||
        self.refill_user(self.customer, 10)
 | 
			
		||||
        force_refill_user(self.customer, 10)
 | 
			
		||||
        self.login_in_bar()
 | 
			
		||||
 | 
			
		||||
        for item in [
 | 
			
		||||
@@ -472,14 +487,12 @@ class TestCounterClick(TestFullClickBase):
 | 
			
		||||
            BasketItem(self.beer.id, -1),
 | 
			
		||||
            BasketItem(None, 1),
 | 
			
		||||
            BasketItem(self.beer.id, None),
 | 
			
		||||
            BasketItem(None, None),
 | 
			
		||||
        ]:
 | 
			
		||||
            assert self.submit_basket(self.customer, [item]).status_code == 200
 | 
			
		||||
 | 
			
		||||
            assert self.updated_amount(self.customer) == Decimal("10")
 | 
			
		||||
 | 
			
		||||
    def test_click_not_enough_money(self):
 | 
			
		||||
        self.refill_user(self.customer, 10)
 | 
			
		||||
        force_refill_user(self.customer, 10)
 | 
			
		||||
        self.login_in_bar()
 | 
			
		||||
        res = self.submit_basket(
 | 
			
		||||
            self.customer,
 | 
			
		||||
@@ -509,7 +522,7 @@ class TestCounterClick(TestFullClickBase):
 | 
			
		||||
        assert self.updated_amount(self.customer) == 0
 | 
			
		||||
 | 
			
		||||
    def test_recordings(self):
 | 
			
		||||
        self.refill_user(self.customer, self.cons.selling_price * 3)
 | 
			
		||||
        force_refill_user(self.customer, self.cons.selling_price * 3)
 | 
			
		||||
        self.login_in_bar(self.barmen)
 | 
			
		||||
        res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
 | 
			
		||||
        assert res.status_code == 302
 | 
			
		||||
 
 | 
			
		||||
@@ -12,18 +12,17 @@
 | 
			
		||||
# OR WITHIN THE LOCAL FILE "LICENSE"
 | 
			
		||||
#
 | 
			
		||||
#
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
from datetime import timezone as tz
 | 
			
		||||
 | 
			
		||||
from django import forms
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.http import HttpResponseRedirect
 | 
			
		||||
from django.contrib.auth.mixins import PermissionRequiredMixin
 | 
			
		||||
from django.http import Http404
 | 
			
		||||
from django.urls import reverse, reverse_lazy
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from django.views.generic import DetailView, ListView
 | 
			
		||||
from django.views.generic.edit import UpdateView
 | 
			
		||||
from django.views.generic.edit import BaseFormView, UpdateView
 | 
			
		||||
 | 
			
		||||
from core.auth.mixins import CanViewMixin
 | 
			
		||||
from core.models import User
 | 
			
		||||
from counter.forms import CashSummaryFormBase
 | 
			
		||||
from counter.models import (
 | 
			
		||||
    CashRegisterSummary,
 | 
			
		||||
@@ -31,9 +30,7 @@ from counter.models import (
 | 
			
		||||
    Counter,
 | 
			
		||||
    Refilling,
 | 
			
		||||
)
 | 
			
		||||
from counter.utils import is_logged_in_counter
 | 
			
		||||
from counter.views.mixins import (
 | 
			
		||||
    CounterAdminMixin,
 | 
			
		||||
    CounterAdminTabsMixin,
 | 
			
		||||
    CounterTabsMixin,
 | 
			
		||||
)
 | 
			
		||||
@@ -157,11 +154,9 @@ class CashRegisterSummaryForm(forms.Form):
 | 
			
		||||
        else:
 | 
			
		||||
            self.instance = None
 | 
			
		||||
 | 
			
		||||
    def save(self, counter=None):
 | 
			
		||||
    def save(self, counter: Counter | None = None, user: User | None = None):
 | 
			
		||||
        cd = self.cleaned_data
 | 
			
		||||
        summary = self.instance or CashRegisterSummary(
 | 
			
		||||
            counter=counter, user=counter.get_random_barman()
 | 
			
		||||
        )
 | 
			
		||||
        summary = self.instance or CashRegisterSummary(counter=counter, user=user)
 | 
			
		||||
        summary.comment = cd["comment"]
 | 
			
		||||
        summary.emptied = cd["emptied"]
 | 
			
		||||
        summary.save()
 | 
			
		||||
@@ -247,48 +242,33 @@ class CashRegisterSummaryForm(forms.Form):
 | 
			
		||||
            summary.delete()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CounterCashSummaryView(CounterTabsMixin, CanViewMixin, DetailView):
 | 
			
		||||
class CounterCashSummaryView(
 | 
			
		||||
    CounterTabsMixin, PermissionRequiredMixin, BaseFormView, DetailView
 | 
			
		||||
):
 | 
			
		||||
    """Provide the cash summary form."""
 | 
			
		||||
 | 
			
		||||
    model = Counter
 | 
			
		||||
    pk_url_kwarg = "counter_id"
 | 
			
		||||
    template_name = "counter/cash_register_summary.jinja"
 | 
			
		||||
    current_tab = "cash_summary"
 | 
			
		||||
    permission_required = "counter.add_cashregistersummary"
 | 
			
		||||
    form_class = CashRegisterSummaryForm
 | 
			
		||||
 | 
			
		||||
    def dispatch(self, request, *args, **kwargs):
 | 
			
		||||
        """We have here again a very particular right handling."""
 | 
			
		||||
        self.object = self.get_object()
 | 
			
		||||
        if is_logged_in_counter(request) and self.object.barmen_list:
 | 
			
		||||
            return super().dispatch(request, *args, **kwargs)
 | 
			
		||||
        return HttpResponseRedirect(
 | 
			
		||||
            reverse("counter:details", kwargs={"counter_id": self.object.id})
 | 
			
		||||
            + "?bad_location"
 | 
			
		||||
        )
 | 
			
		||||
        if self.object.type != "BAR":
 | 
			
		||||
            raise Http404
 | 
			
		||||
        return super().dispatch(request, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def get(self, request, *args, **kwargs):
 | 
			
		||||
        self.object = self.get_object()
 | 
			
		||||
        self.form = CashRegisterSummaryForm()
 | 
			
		||||
        return super().get(request, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def post(self, request, *args, **kwargs):
 | 
			
		||||
        self.object = self.get_object()
 | 
			
		||||
        self.form = CashRegisterSummaryForm(request.POST)
 | 
			
		||||
        if self.form.is_valid():
 | 
			
		||||
            self.form.save(self.object)
 | 
			
		||||
            return HttpResponseRedirect(self.get_success_url())
 | 
			
		||||
        return super().get(request, *args, **kwargs)
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        form.save(counter=self.object, user=self.request.user)
 | 
			
		||||
        return super().form_valid(form)
 | 
			
		||||
 | 
			
		||||
    def get_success_url(self):
 | 
			
		||||
        return reverse_lazy("counter:details", kwargs={"counter_id": self.object.id})
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        """Add form to the context."""
 | 
			
		||||
        kwargs = super().get_context_data(**kwargs)
 | 
			
		||||
        kwargs["form"] = self.form
 | 
			
		||||
        return kwargs
 | 
			
		||||
        return reverse("counter:details", kwargs={"counter_id": self.object.id})
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CashSummaryEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
 | 
			
		||||
class CashSummaryEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
 | 
			
		||||
    """Edit cash summaries."""
 | 
			
		||||
 | 
			
		||||
    model = CashRegisterSummary
 | 
			
		||||
@@ -297,12 +277,11 @@ class CashSummaryEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
 | 
			
		||||
    pk_url_kwarg = "cashsummary_id"
 | 
			
		||||
    form_class = CashRegisterSummaryForm
 | 
			
		||||
    current_tab = "cash_summary"
 | 
			
		||||
 | 
			
		||||
    def get_success_url(self):
 | 
			
		||||
        return reverse("counter:cash_summary_list")
 | 
			
		||||
    permission_required = "counter.change_cashregistersummary"
 | 
			
		||||
    success_url = reverse_lazy("counter:cash_summary_list")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CashSummaryListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
 | 
			
		||||
class CashSummaryListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
 | 
			
		||||
    """Display a list of cash summaries."""
 | 
			
		||||
 | 
			
		||||
    model = CashRegisterSummary
 | 
			
		||||
@@ -311,6 +290,7 @@ class CashSummaryListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
 | 
			
		||||
    current_tab = "cash_summary"
 | 
			
		||||
    queryset = CashRegisterSummary.objects.all().order_by("-date")
 | 
			
		||||
    paginate_by = settings.SITH_COUNTER_CASH_SUMMARY_LENGTH
 | 
			
		||||
    permission_required = "counter.view_cashregistersummary"
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        """Add sums to the context."""
 | 
			
		||||
@@ -321,12 +301,12 @@ class CashSummaryListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
 | 
			
		||||
        kwargs["refilling_sums"] = {}
 | 
			
		||||
        for c in Counter.objects.filter(type="BAR").all():
 | 
			
		||||
            refillings = Refilling.objects.filter(counter=c)
 | 
			
		||||
            cashredistersummaries = CashRegisterSummary.objects.filter(counter=c)
 | 
			
		||||
            cash_register_summaries = CashRegisterSummary.objects.filter(counter=c)
 | 
			
		||||
            if form.is_valid() and form.cleaned_data["begin_date"]:
 | 
			
		||||
                refillings = refillings.filter(
 | 
			
		||||
                    date__gte=form.cleaned_data["begin_date"]
 | 
			
		||||
                )
 | 
			
		||||
                cashredistersummaries = cashredistersummaries.filter(
 | 
			
		||||
                cash_register_summaries = cash_register_summaries.filter(
 | 
			
		||||
                    date__gte=form.cleaned_data["begin_date"]
 | 
			
		||||
                )
 | 
			
		||||
            else:
 | 
			
		||||
@@ -337,23 +317,16 @@ class CashSummaryListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
 | 
			
		||||
                )
 | 
			
		||||
                if last_summary:
 | 
			
		||||
                    refillings = refillings.filter(date__gt=last_summary.date)
 | 
			
		||||
                    cashredistersummaries = cashredistersummaries.filter(
 | 
			
		||||
                    cash_register_summaries = cash_register_summaries.filter(
 | 
			
		||||
                        date__gt=last_summary.date
 | 
			
		||||
                    )
 | 
			
		||||
                else:
 | 
			
		||||
                    refillings = refillings.filter(
 | 
			
		||||
                        date__gte=datetime(year=1994, month=5, day=17, tzinfo=tz.utc)
 | 
			
		||||
                    )  # My birth date should be old enough
 | 
			
		||||
                    cashredistersummaries = cashredistersummaries.filter(
 | 
			
		||||
                        date__gte=datetime(year=1994, month=5, day=17, tzinfo=tz.utc)
 | 
			
		||||
                    )
 | 
			
		||||
            if form.is_valid() and form.cleaned_data["end_date"]:
 | 
			
		||||
                refillings = refillings.filter(date__lte=form.cleaned_data["end_date"])
 | 
			
		||||
                cashredistersummaries = cashredistersummaries.filter(
 | 
			
		||||
                cash_register_summaries = cash_register_summaries.filter(
 | 
			
		||||
                    date__lte=form.cleaned_data["end_date"]
 | 
			
		||||
                )
 | 
			
		||||
            kwargs["summaries_sums"][c.name] = sum(
 | 
			
		||||
                [s.get_total() for s in cashredistersummaries.all()]
 | 
			
		||||
                [s.get_total() for s in cash_register_summaries.all()]
 | 
			
		||||
            )
 | 
			
		||||
            kwargs["refilling_sums"][c.name] = sum([s.amount for s in refillings.all()])
 | 
			
		||||
        return kwargs
 | 
			
		||||
 
 | 
			
		||||
@@ -12,23 +12,14 @@
 | 
			
		||||
# OR WITHIN THE LOCAL FILE "LICENSE"
 | 
			
		||||
#
 | 
			
		||||
#
 | 
			
		||||
import math
 | 
			
		||||
 | 
			
		||||
from django.core.exceptions import PermissionDenied
 | 
			
		||||
from django.db import transaction
 | 
			
		||||
from django.db.models import Q
 | 
			
		||||
from django.forms import (
 | 
			
		||||
    BaseFormSet,
 | 
			
		||||
    Form,
 | 
			
		||||
    IntegerField,
 | 
			
		||||
    ValidationError,
 | 
			
		||||
    formset_factory,
 | 
			
		||||
)
 | 
			
		||||
from django.http import Http404
 | 
			
		||||
from django.shortcuts import get_object_or_404, redirect, resolve_url
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from django.utils.safestring import SafeString
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from django.views.generic import FormView
 | 
			
		||||
from django.views.generic.detail import SingleObjectMixin
 | 
			
		||||
from ninja.main import HttpRequest
 | 
			
		||||
@@ -36,11 +27,10 @@ from ninja.main import HttpRequest
 | 
			
		||||
from core.auth.mixins import CanViewMixin
 | 
			
		||||
from core.models import User
 | 
			
		||||
from core.views.mixins import FragmentMixin, UseFragmentsMixin
 | 
			
		||||
from counter.forms import RefillForm
 | 
			
		||||
from counter.forms import BasketForm, RefillForm
 | 
			
		||||
from counter.models import (
 | 
			
		||||
    Counter,
 | 
			
		||||
    Customer,
 | 
			
		||||
    Product,
 | 
			
		||||
    ReturnableProduct,
 | 
			
		||||
    Selling,
 | 
			
		||||
)
 | 
			
		||||
@@ -57,113 +47,6 @@ def get_operator(request: HttpRequest, counter: Counter, customer: Customer) ->
 | 
			
		||||
    return counter.get_random_barman()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ProductForm(Form):
 | 
			
		||||
    quantity = IntegerField(min_value=1)
 | 
			
		||||
    id = IntegerField(min_value=0)
 | 
			
		||||
 | 
			
		||||
    def __init__(
 | 
			
		||||
        self,
 | 
			
		||||
        customer: Customer,
 | 
			
		||||
        counter: Counter,
 | 
			
		||||
        allowed_products: dict[int, Product],
 | 
			
		||||
        *args,
 | 
			
		||||
        **kwargs,
 | 
			
		||||
    ):
 | 
			
		||||
        self.customer = customer  # Used by formset
 | 
			
		||||
        self.counter = counter  # Used by formset
 | 
			
		||||
        self.allowed_products = allowed_products
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def clean_id(self):
 | 
			
		||||
        data = self.cleaned_data["id"]
 | 
			
		||||
 | 
			
		||||
        # We store self.product so we can use it later on the formset validation
 | 
			
		||||
        # And also in the global clean
 | 
			
		||||
        self.product = self.allowed_products.get(data, None)
 | 
			
		||||
        if self.product is None:
 | 
			
		||||
            raise ValidationError(
 | 
			
		||||
                _("The selected product isn't available for this user")
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        return data
 | 
			
		||||
 | 
			
		||||
    def clean(self):
 | 
			
		||||
        cleaned_data = super().clean()
 | 
			
		||||
        if len(self.errors) > 0:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        # Compute prices
 | 
			
		||||
        cleaned_data["bonus_quantity"] = 0
 | 
			
		||||
        if self.product.tray:
 | 
			
		||||
            cleaned_data["bonus_quantity"] = math.floor(
 | 
			
		||||
                cleaned_data["quantity"] / Product.QUANTITY_FOR_TRAY_PRICE
 | 
			
		||||
            )
 | 
			
		||||
        cleaned_data["total_price"] = self.product.price * (
 | 
			
		||||
            cleaned_data["quantity"] - cleaned_data["bonus_quantity"]
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        return cleaned_data
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BaseBasketForm(BaseFormSet):
 | 
			
		||||
    def clean(self):
 | 
			
		||||
        if len(self.forms) == 0:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        self._check_forms_have_errors()
 | 
			
		||||
        self._check_product_are_unique()
 | 
			
		||||
        self._check_recorded_products(self[0].customer)
 | 
			
		||||
        self._check_enough_money(self[0].counter, self[0].customer)
 | 
			
		||||
 | 
			
		||||
    def _check_forms_have_errors(self):
 | 
			
		||||
        if any(len(form.errors) > 0 for form in self):
 | 
			
		||||
            raise ValidationError(_("Submitted basket is invalid"))
 | 
			
		||||
 | 
			
		||||
    def _check_product_are_unique(self):
 | 
			
		||||
        product_ids = {form.cleaned_data["id"] for form in self.forms}
 | 
			
		||||
        if len(product_ids) != len(self.forms):
 | 
			
		||||
            raise ValidationError(_("Duplicated product entries."))
 | 
			
		||||
 | 
			
		||||
    def _check_enough_money(self, counter: Counter, customer: Customer):
 | 
			
		||||
        self.total_price = sum([data["total_price"] for data in self.cleaned_data])
 | 
			
		||||
        if self.total_price > customer.amount:
 | 
			
		||||
            raise ValidationError(_("Not enough money"))
 | 
			
		||||
 | 
			
		||||
    def _check_recorded_products(self, customer: Customer):
 | 
			
		||||
        """Check for, among other things, ecocups and pitchers"""
 | 
			
		||||
        items = {
 | 
			
		||||
            form.cleaned_data["id"]: form.cleaned_data["quantity"]
 | 
			
		||||
            for form in self.forms
 | 
			
		||||
        }
 | 
			
		||||
        ids = list(items.keys())
 | 
			
		||||
        returnables = list(
 | 
			
		||||
            ReturnableProduct.objects.filter(
 | 
			
		||||
                Q(product_id__in=ids) | Q(returned_product_id__in=ids)
 | 
			
		||||
            ).annotate_balance_for(customer)
 | 
			
		||||
        )
 | 
			
		||||
        limit_reached = []
 | 
			
		||||
        for returnable in returnables:
 | 
			
		||||
            returnable.balance += items.get(returnable.product_id, 0)
 | 
			
		||||
        for returnable in returnables:
 | 
			
		||||
            dcons = items.get(returnable.returned_product_id, 0)
 | 
			
		||||
            returnable.balance -= dcons
 | 
			
		||||
            if dcons and returnable.balance < -returnable.max_return:
 | 
			
		||||
                limit_reached.append(returnable.returned_product)
 | 
			
		||||
        if limit_reached:
 | 
			
		||||
            raise ValidationError(
 | 
			
		||||
                _(
 | 
			
		||||
                    "This user have reached his recording limit "
 | 
			
		||||
                    "for the following products : %s"
 | 
			
		||||
                )
 | 
			
		||||
                % ", ".join([str(p) for p in limit_reached])
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
BasketForm = formset_factory(
 | 
			
		||||
    ProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CounterClick(
 | 
			
		||||
    CounterTabsMixin, UseFragmentsMixin, CanViewMixin, SingleObjectMixin, FormView
 | 
			
		||||
):
 | 
			
		||||
 
 | 
			
		||||
@@ -15,11 +15,12 @@
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.core.exceptions import PermissionDenied
 | 
			
		||||
from django.urls import reverse_lazy
 | 
			
		||||
from django.urls import reverse, reverse_lazy
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from django.views.generic.base import View
 | 
			
		||||
 | 
			
		||||
from core.views.mixins import TabedViewMixin
 | 
			
		||||
from counter.utils import is_logged_in_counter
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CounterAdminMixin(View):
 | 
			
		||||
@@ -49,34 +50,40 @@ class CounterTabsMixin(TabedViewMixin):
 | 
			
		||||
        return self.object
 | 
			
		||||
 | 
			
		||||
    def get_list_of_tabs(self):
 | 
			
		||||
        if self.object.type != "BAR":
 | 
			
		||||
            return []
 | 
			
		||||
        tab_list = [
 | 
			
		||||
            {
 | 
			
		||||
                "url": reverse_lazy(
 | 
			
		||||
                "url": reverse(
 | 
			
		||||
                    "counter:details", kwargs={"counter_id": self.object.id}
 | 
			
		||||
                ),
 | 
			
		||||
                "slug": "counter",
 | 
			
		||||
                "name": _("Counter"),
 | 
			
		||||
            }
 | 
			
		||||
        ]
 | 
			
		||||
        if self.object.type == "BAR":
 | 
			
		||||
        if self.request.user.has_perm("counter.add_cashregistersummary"):
 | 
			
		||||
            tab_list.append(
 | 
			
		||||
                {
 | 
			
		||||
                    "url": reverse_lazy(
 | 
			
		||||
                    "url": reverse(
 | 
			
		||||
                        "counter:cash_summary", kwargs={"counter_id": self.object.id}
 | 
			
		||||
                    ),
 | 
			
		||||
                    "slug": "cash_summary",
 | 
			
		||||
                    "name": _("Cash summary"),
 | 
			
		||||
                }
 | 
			
		||||
            )
 | 
			
		||||
        if is_logged_in_counter(self.request):
 | 
			
		||||
            tab_list.append(
 | 
			
		||||
                {
 | 
			
		||||
                    "url": reverse_lazy(
 | 
			
		||||
                    "url": reverse(
 | 
			
		||||
                        "counter:last_ops", kwargs={"counter_id": self.object.id}
 | 
			
		||||
                    ),
 | 
			
		||||
                    "slug": "last_ops",
 | 
			
		||||
                    "name": _("Last operations"),
 | 
			
		||||
                }
 | 
			
		||||
            )
 | 
			
		||||
        if len(tab_list) <= 1:
 | 
			
		||||
            # It would be strange to show only one tab
 | 
			
		||||
            return []
 | 
			
		||||
        return tab_list
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -131,6 +131,31 @@ de données fonctionnent avec l'un comme avec l'autre.
 | 
			
		||||
Heureusement, et grâce à l'ORM de Django, cette
 | 
			
		||||
double compatibilité est presque toujours possible.
 | 
			
		||||
 | 
			
		||||
### Celery
 | 
			
		||||
 | 
			
		||||
[Site officiel](https://docs.celeryq.dev/en/stable/)
 | 
			
		||||
 | 
			
		||||
Dans certaines situations, on veut séparer une tâche
 | 
			
		||||
pour la faire tourner dans son coin.
 | 
			
		||||
Deux cas qui correspondent à cette situation sont :
 | 
			
		||||
 | 
			
		||||
- les tâches longues à exécuter 
 | 
			
		||||
  (comme l'envoi de mail ou la génération de documents),
 | 
			
		||||
  pour lesquelles on veut pouvoir dire à l'utilisateur
 | 
			
		||||
  que sa requête a été prise en compte, sans pour autant
 | 
			
		||||
  le faire trop patienter
 | 
			
		||||
- les tâches régulières séparées du cycle requête/réponse.
 | 
			
		||||
 | 
			
		||||
Pour ça, nous utilisons Celery.
 | 
			
		||||
Grâce à son intégration avec Django,
 | 
			
		||||
il permet de mettre en place une queue de message
 | 
			
		||||
avec assez peu complexité ajoutée.
 | 
			
		||||
 | 
			
		||||
En outre, ses extensions `django-celery-results`
 | 
			
		||||
et `django-celery-beat` enrichissent son intégration
 | 
			
		||||
avec django et offrent des moyens de manipuler certaines
 | 
			
		||||
tâches directement dans l'interface admin de django.
 | 
			
		||||
 | 
			
		||||
## Frontend
 | 
			
		||||
 | 
			
		||||
### Jinja2
 | 
			
		||||
 
 | 
			
		||||
@@ -1 +0,0 @@
 | 
			
		||||
::: launderette.models
 | 
			
		||||
@@ -1 +0,0 @@
 | 
			
		||||
::: launderette.views
 | 
			
		||||
@@ -120,7 +120,7 @@ les conflits avec les instances de redis déjà en fonctionnement.
 | 
			
		||||
 | 
			
		||||
```dotenv
 | 
			
		||||
REDIS_PORT=6379
 | 
			
		||||
CACHE_URL=redis://127.0.0.1:${REDIS_PORT}/0
 | 
			
		||||
CACHE_URL=redis://127.0.0.1:6379/0
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Si on souhaite configurer redis pour communiquer via un socket :
 | 
			
		||||
@@ -151,7 +151,7 @@ ALTER ROLE sith SET client_encoding TO 'utf8';
 | 
			
		||||
ALTER ROLE sith SET default_transaction_isolation TO 'read committed';
 | 
			
		||||
ALTER ROLE sith SET timezone TO 'UTC';
 | 
			
		||||
 | 
			
		||||
GRANT ALL PRIVILEGES ON DATABASE sith TO SITH;
 | 
			
		||||
GRANT ALL PRIVILEGES ON DATABASE sith TO sith;
 | 
			
		||||
\q
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
@@ -279,6 +279,26 @@ Toutes les requêtes vers des fichiers statiques et les medias publiques
 | 
			
		||||
seront servies directement par nginx.
 | 
			
		||||
Toutes les autres requêtes seront transmises au serveur django.
 | 
			
		||||
 | 
			
		||||
## Celery
 | 
			
		||||
 | 
			
		||||
Celery ne tourne pas dans django.
 | 
			
		||||
C'est une application à part, avec ses propres processus,
 | 
			
		||||
qui tourne de manière indépendante et qui ne communique
 | 
			
		||||
que par messages avec l'instance de django.
 | 
			
		||||
 | 
			
		||||
Pour faire tourner Celery, faites la commande suivante dans 
 | 
			
		||||
un terminal à part :
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
uv run celery -A sith worker --beat -l INFO 
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
!!!note
 | 
			
		||||
 | 
			
		||||
    Nous utilisons Redis comme broker pour Celery,
 | 
			
		||||
    donc vous devez aussi configurer l'URL du broker,
 | 
			
		||||
    de la même manière que ce qui est décrit plus haut
 | 
			
		||||
    pour Redis.
 | 
			
		||||
 | 
			
		||||
## Mettre à jour la base de données antispam
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -100,7 +100,6 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
 | 
			
		||||
    Python ne fait pas parti des dépendances puisqu'il est automatiquement
 | 
			
		||||
    installé par uv.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## Finaliser l'installation
 | 
			
		||||
 | 
			
		||||
Clonez le projet (depuis votre console WSL, si vous utilisez WSL)
 | 
			
		||||
@@ -113,6 +112,7 @@ cd sith
 | 
			
		||||
# Création de l'environnement et installation des dépendances
 | 
			
		||||
uv sync
 | 
			
		||||
npm install # Dépendances frontend
 | 
			
		||||
cp .env.example .env # Configuration par défaut
 | 
			
		||||
uv run ./manage.py install_xapian
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
@@ -122,17 +122,12 @@ uv run ./manage.py install_xapian
 | 
			
		||||
    de texte à l'écran.
 | 
			
		||||
    C'est normal, il ne faut pas avoir peur.
 | 
			
		||||
 | 
			
		||||
Une fois les dépendances installées, il faut encore
 | 
			
		||||
mettre en place quelques éléments de configuration,
 | 
			
		||||
qui peuvent varier d'un environnement à l'autre.
 | 
			
		||||
 | 
			
		||||
La modification de la configuration passe par la modification de variables
 | 
			
		||||
d'environnement.
 | 
			
		||||
Ces variables sont stockées dans un fichier `.env`.
 | 
			
		||||
Pour le créer, vous pouvez copier le fichier `.env.example` :
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
cp .env.example .env
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Les variables par défaut contenues dans le fichier `.env`
 | 
			
		||||
Les variables par défaut contenues dans le fichier `.env.example`
 | 
			
		||||
devraient convenir pour le développement, sans modification.
 | 
			
		||||
 | 
			
		||||
Maintenant que les dépendances sont installées
 | 
			
		||||
 
 | 
			
		||||
@@ -32,48 +32,39 @@ susnommés afin de comprendre comment celui-ci marche.
 | 
			
		||||
 | 
			
		||||
Cette application contient les vues suivantes : 
 | 
			
		||||
 | 
			
		||||
- `eboutic_main` (GET) : la vue retournant la page principale de la boutique en ligne.
 | 
			
		||||
- `EbouticMainView` (GET/POST) : la vue retournant la page principale de la boutique en ligne.
 | 
			
		||||
Cette vue effectue un filtrage des produits à montrer à l'utilisateur en
 | 
			
		||||
fonction de ce qu'il a le droit d'acheter.
 | 
			
		||||
Si cette vue est appelée lors d'une redirection parce qu'une erreur 
 | 
			
		||||
est survenue au cours de la navigation sur la boutique, il est possible
 | 
			
		||||
de donner les messages d'erreur à donner à l'utilisateur dans la session 
 | 
			
		||||
avec la clef ``"errors"``.
 | 
			
		||||
Elle est en charge de récupérer le formulaire de création d'un panier et
 | 
			
		||||
redirige alors vers la vue de checkout.
 | 
			
		||||
- ``payment_result`` (GET) : retourne une page assez simple disant à l'utilisateur
 | 
			
		||||
si son paiement a échoué ou réussi. Cette vue est appelée par redirection
 | 
			
		||||
lorsque l'utilisateur paye son panier avec son argent du compte AE.
 | 
			
		||||
- ``EbouticCommand`` (POST) : traite la soumission d'un panier par l'utilisateur.
 | 
			
		||||
Lors de l'appel de cette vue, la requête doit contenir un cookie avec l'état
 | 
			
		||||
du panier à valider. Ce panier doit strictement être de la forme : 
 | 
			
		||||
```
 | 
			
		||||
[
 | 
			
		||||
  {"id": <int>, "name": <str>, "quantity": <int>, "unit_price": <float>},
 | 
			
		||||
  {"id": <int>, "name": <str>, "quantity": <int>, "unit_price": <float>},
 | 
			
		||||
  <etc.>
 | 
			
		||||
]
 | 
			
		||||
```
 | 
			
		||||
Si le panier est mal formaté ou contient des valeurs invalides, 
 | 
			
		||||
une redirection est faite vers `eboutic_main`.
 | 
			
		||||
- ``pay_with_sith`` (POST) : paie le panier avec l'argent présent sur le compte
 | 
			
		||||
- ``EbouticCheckout`` (GET/POST) : Page récapitulant le contenu d'un panier.
 | 
			
		||||
Permet de sélectionner le moyen de paiement et de mettre à jour ses coordonnées
 | 
			
		||||
de paiement par carte bancaire.
 | 
			
		||||
- ``PayWithSith`` (POST) : paie le panier avec l'argent présent sur le compte
 | 
			
		||||
AE. Redirige vers `payment_result`.
 | 
			
		||||
- ``ETransactionAutoAnswer`` (GET) : vue destinée à communiquer avec le service 
 | 
			
		||||
de paiement bancaire pour valider ou non le paiement de l'utilisateur.
 | 
			
		||||
- ``BillingInfoFormFragment`` (GET/POST) : vue destinée à gérer les informations de paiement de l'utilisateur courant.
 | 
			
		||||
 | 
			
		||||
# Les templates
 | 
			
		||||
 | 
			
		||||
- ``eboutic_payment_result.jinja`` : très court template contenant juste
 | 
			
		||||
un message pour dire à l'utilisateur si son achat s'est bien déroulé.
 | 
			
		||||
Retourné par la vue ``payment_result``.
 | 
			
		||||
- ``eboutic_makecommand.jinja`` : template contenant un résumé du panier et deux
 | 
			
		||||
- ``eboutic_checkout.jinja`` : template contenant un résumé du panier et deux
 | 
			
		||||
boutons, un pour payer avec le site AE et l'autre pour payer par carte bancaire.
 | 
			
		||||
Retourné par la vue ``EbouticCommand``
 | 
			
		||||
Retourné par la vue ``EbouticCheckout``
 | 
			
		||||
- ``eboutic_billing_info.jinja`` : formulaire de modification des coordonnées bancaires.
 | 
			
		||||
Elle permet également de mettre à jour ses coordonnées de paiement
 | 
			
		||||
- ``eboutic_main.jinja`` : le plus gros template de cette application. Contient
 | 
			
		||||
une interface pour que l'utilisateur puisse consulter les produits et remplir
 | 
			
		||||
son panier. Les opérations de remplissage du panier se font entièrement côté client.
 | 
			
		||||
À chaque clic pour ajouter ou retirer un élément du panier, le script JS
 | 
			
		||||
  (AlpineJS, plus précisément) édite en même temps un cookie. 
 | 
			
		||||
Au moment de la validation du panier, ce cookie est envoyé au serveur pour
 | 
			
		||||
vérifier que la commande est valide et payer.
 | 
			
		||||
  (AlpineJS, plus précisément) édite en même temps le localStorage du navigateur. 
 | 
			
		||||
Cette vue fabrique dynamiquement un formulaire qui sera soumis au serveur.
 | 
			
		||||
 | 
			
		||||
# Les modèles
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,22 +1,20 @@
 | 
			
		||||
from ninja_extra import ControllerBase, api_controller, route
 | 
			
		||||
from ninja_extra.exceptions import NotFound
 | 
			
		||||
from ninja_extra.permissions import IsAuthenticated
 | 
			
		||||
 | 
			
		||||
from core.auth.api_permissions import CanView
 | 
			
		||||
from counter.models import BillingInfo
 | 
			
		||||
from eboutic.models import Basket
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@api_controller("/etransaction", permissions=[IsAuthenticated])
 | 
			
		||||
@api_controller("/etransaction", permissions=[CanView])
 | 
			
		||||
class EtransactionInfoController(ControllerBase):
 | 
			
		||||
    @route.get("/data", url_name="etransaction_data")
 | 
			
		||||
    def fetch_etransaction_data(self):
 | 
			
		||||
    @route.get("/data/{basket_id}", url_name="etransaction_data")
 | 
			
		||||
    def fetch_etransaction_data(self, basket_id: int):
 | 
			
		||||
        """Generate the data to pay an eboutic command with paybox.
 | 
			
		||||
 | 
			
		||||
        The data is generated with the basket that is used by the current session.
 | 
			
		||||
        """
 | 
			
		||||
        basket = Basket.from_session(self.context.request.session)
 | 
			
		||||
        if basket is None:
 | 
			
		||||
            raise NotFound
 | 
			
		||||
        basket: Basket = self.get_object_or_exception(Basket, pk=basket_id)
 | 
			
		||||
        try:
 | 
			
		||||
            return dict(basket.get_e_transaction_data())
 | 
			
		||||
        except BillingInfo.DoesNotExist as e:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										128
									
								
								eboutic/forms.py
									
									
									
									
									
								
							
							
						
						
									
										128
									
								
								eboutic/forms.py
									
									
									
									
									
								
							@@ -1,128 +0,0 @@
 | 
			
		||||
#
 | 
			
		||||
# Copyright 2022
 | 
			
		||||
# - Maréchal <thgirod@hotmail.com
 | 
			
		||||
#
 | 
			
		||||
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
 | 
			
		||||
# http://ae.utbm.fr.
 | 
			
		||||
#
 | 
			
		||||
# This program is free software; you can redistribute it and/or modify it under
 | 
			
		||||
# the terms of the GNU General Public License a published by the Free Software
 | 
			
		||||
# Foundation; either version 3 of the License, or (at your option) any later
 | 
			
		||||
# version.
 | 
			
		||||
#
 | 
			
		||||
# This program is distributed in the hope that it will be useful, but WITHOUT
 | 
			
		||||
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 | 
			
		||||
# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
 | 
			
		||||
# details.
 | 
			
		||||
#
 | 
			
		||||
# You should have received a copy of the GNU General Public License along with
 | 
			
		||||
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
 | 
			
		||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
 | 
			
		||||
#
 | 
			
		||||
#
 | 
			
		||||
from functools import cached_property
 | 
			
		||||
from urllib.parse import unquote
 | 
			
		||||
 | 
			
		||||
from django.http import HttpRequest
 | 
			
		||||
from django.utils.translation import gettext as _
 | 
			
		||||
from pydantic import ValidationError
 | 
			
		||||
 | 
			
		||||
from eboutic.models import get_eboutic_products
 | 
			
		||||
from eboutic.schemas import PurchaseItemList, PurchaseItemSchema
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BasketForm:
 | 
			
		||||
    """Class intended to perform checks on the request sended to the server when
 | 
			
		||||
    the user submits his basket from /eboutic/.
 | 
			
		||||
 | 
			
		||||
    Because it must check an unknown number of fields, coming from a cookie
 | 
			
		||||
    and needing some databases checks to be performed, inheriting from forms.Form
 | 
			
		||||
    or using formset would have been likely to end in a big ball of wibbly-wobbly hacky stuff.
 | 
			
		||||
    Thus this class is a pure standalone and performs its operations by its own means.
 | 
			
		||||
    However, it still tries to share some similarities with a standard django Form.
 | 
			
		||||
 | 
			
		||||
    Examples:
 | 
			
		||||
        ::
 | 
			
		||||
 | 
			
		||||
            def my_view(request):
 | 
			
		||||
                form = BasketForm(request)
 | 
			
		||||
                form.clean()
 | 
			
		||||
                if form.is_valid():
 | 
			
		||||
                    # perform operations
 | 
			
		||||
                else:
 | 
			
		||||
                    errors = form.get_error_messages()
 | 
			
		||||
 | 
			
		||||
                    # return the cookie that was in the request, but with all
 | 
			
		||||
                    # incorrects elements removed
 | 
			
		||||
                    cookie = form.get_cleaned_cookie()
 | 
			
		||||
 | 
			
		||||
    You can also use a little shortcut by directly calling `form.is_valid()`
 | 
			
		||||
    without calling `form.clean()`. In this case, the latter method shall be
 | 
			
		||||
    implicitly called.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def __init__(self, request: HttpRequest):
 | 
			
		||||
        self.user = request.user
 | 
			
		||||
        self.cookies = request.COOKIES
 | 
			
		||||
        self.error_messages = set()
 | 
			
		||||
        self.correct_items = []
 | 
			
		||||
 | 
			
		||||
    def clean(self) -> None:
 | 
			
		||||
        """Perform all the checks, but return nothing.
 | 
			
		||||
        To know if the form is valid, the `is_valid()` method must be used.
 | 
			
		||||
 | 
			
		||||
        The form shall be considered as valid if it meets all the following conditions :
 | 
			
		||||
            - it contains a "basket_items" key in the cookies of the request given in the constructor
 | 
			
		||||
            - this cookie is a list of objects formatted this way : `[{'id': <int>, 'quantity': <int>,
 | 
			
		||||
             'name': <str>, 'unit_price': <float>}, ...]`. The order of the fields in each object does not matter
 | 
			
		||||
            - all the ids are positive integers
 | 
			
		||||
            - all the ids refer to products available in the EBOUTIC
 | 
			
		||||
            - all the ids refer to products the user is allowed to buy
 | 
			
		||||
            - all the quantities are positive integers
 | 
			
		||||
        """
 | 
			
		||||
        try:
 | 
			
		||||
            basket = PurchaseItemList.validate_json(
 | 
			
		||||
                unquote(self.cookies.get("basket_items", "[]"))
 | 
			
		||||
            )
 | 
			
		||||
        except ValidationError:
 | 
			
		||||
            self.error_messages.add(_("The request was badly formatted."))
 | 
			
		||||
            return
 | 
			
		||||
        if len(basket) == 0:
 | 
			
		||||
            self.error_messages.add(_("Your basket is empty."))
 | 
			
		||||
            return
 | 
			
		||||
        existing_ids = {product.id for product in get_eboutic_products(self.user)}
 | 
			
		||||
        for item in basket:
 | 
			
		||||
            # check a product with this id does exist
 | 
			
		||||
            if item.product_id in existing_ids:
 | 
			
		||||
                self.correct_items.append(item)
 | 
			
		||||
            else:
 | 
			
		||||
                self.error_messages.add(
 | 
			
		||||
                    _(
 | 
			
		||||
                        "%(name)s : this product does not exist or may no longer be available."
 | 
			
		||||
                    )
 | 
			
		||||
                    % {"name": item.name}
 | 
			
		||||
                )
 | 
			
		||||
                continue
 | 
			
		||||
        # this function does not return anything.
 | 
			
		||||
        # instead, it fills a set containing the collected error messages
 | 
			
		||||
        # an empty set means that no error was seen thus everything is ok
 | 
			
		||||
        # and the form is valid.
 | 
			
		||||
        # a non-empty set means there was at least one error thus
 | 
			
		||||
        # the form is invalid
 | 
			
		||||
 | 
			
		||||
    def is_valid(self) -> bool:
 | 
			
		||||
        """Return True if the form is correct else False.
 | 
			
		||||
 | 
			
		||||
        If the `clean()` method has not been called beforehand, call it.
 | 
			
		||||
        """
 | 
			
		||||
        if not self.error_messages and not self.correct_items:
 | 
			
		||||
            self.clean()
 | 
			
		||||
        return not self.error_messages
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def errors(self) -> list[str]:
 | 
			
		||||
        return list(self.error_messages)
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def cleaned_data(self) -> list[PurchaseItemSchema]:
 | 
			
		||||
        return self.correct_items
 | 
			
		||||
@@ -28,18 +28,27 @@ from django.utils.translation import gettext_lazy as _
 | 
			
		||||
 | 
			
		||||
from core.models import User
 | 
			
		||||
from counter.fields import CurrencyField
 | 
			
		||||
from counter.models import BillingInfo, Counter, Customer, Product, Refilling, Selling
 | 
			
		||||
from counter.models import (
 | 
			
		||||
    BillingInfo,
 | 
			
		||||
    Counter,
 | 
			
		||||
    Customer,
 | 
			
		||||
    Product,
 | 
			
		||||
    Refilling,
 | 
			
		||||
    Selling,
 | 
			
		||||
    get_eboutic,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_eboutic_products(user: User) -> list[Product]:
 | 
			
		||||
    products = (
 | 
			
		||||
        Counter.objects.get(type="EBOUTIC")
 | 
			
		||||
        get_eboutic()
 | 
			
		||||
        .products.filter(product_type__isnull=False)
 | 
			
		||||
        .filter(archived=False)
 | 
			
		||||
        .filter(limit_age__lte=user.age)
 | 
			
		||||
        .annotate(order=F("product_type__order"))
 | 
			
		||||
        .annotate(category=F("product_type__name"))
 | 
			
		||||
        .annotate(category_comment=F("product_type__comment"))
 | 
			
		||||
        .annotate(price=F("selling_price"))  # <-- selected price for basket validation
 | 
			
		||||
        .prefetch_related("buying_groups")  # <-- used in `Product.can_be_sold_to`
 | 
			
		||||
    )
 | 
			
		||||
    return [p for p in products if p.can_be_sold_to(user)]
 | 
			
		||||
@@ -84,6 +93,9 @@ class Basket(models.Model):
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return f"{self.user}'s basket ({self.items.all().count()} items)"
 | 
			
		||||
 | 
			
		||||
    def can_be_viewed_by(self, user):
 | 
			
		||||
        return self.user == user
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def contains_refilling_item(self) -> bool:
 | 
			
		||||
        return self.items.filter(
 | 
			
		||||
@@ -98,13 +110,6 @@ class Basket(models.Model):
 | 
			
		||||
            )["total"]
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def from_session(cls, session) -> Basket | None:
 | 
			
		||||
        """The basket stored in the session object, if it exists."""
 | 
			
		||||
        if "basket_id" in session:
 | 
			
		||||
            return cls.objects.filter(id=session["basket_id"]).first()
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    def generate_sales(self, counter, seller: User, payment_method: str):
 | 
			
		||||
        """Generate a list of sold items corresponding to the items
 | 
			
		||||
        of this basket WITHOUT saving them NOR deleting the basket.
 | 
			
		||||
@@ -139,7 +144,7 @@ class Basket(models.Model):
 | 
			
		||||
                    club=product.club,
 | 
			
		||||
                    product=product,
 | 
			
		||||
                    seller=seller,
 | 
			
		||||
                    customer=self.user.customer,
 | 
			
		||||
                    customer=Customer.get_or_create(self.user)[0],
 | 
			
		||||
                    unit_price=item.product_unit_price,
 | 
			
		||||
                    quantity=item.quantity,
 | 
			
		||||
                    payment_method=payment_method,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,18 @@
 | 
			
		||||
import { etransactioninfoFetchEtransactionData } from "#openapi";
 | 
			
		||||
 | 
			
		||||
document.addEventListener("alpine:init", () => {
 | 
			
		||||
  Alpine.data("etransaction", (initialData) => ({
 | 
			
		||||
  Alpine.data("etransaction", (initialData, basketId: number) => ({
 | 
			
		||||
    data: initialData,
 | 
			
		||||
    isCbAvailable: Object.keys(initialData).length > 0,
 | 
			
		||||
 | 
			
		||||
    async fill() {
 | 
			
		||||
      this.isCbAvailable = false;
 | 
			
		||||
      const res = await etransactioninfoFetchEtransactionData();
 | 
			
		||||
      const res = await etransactioninfoFetchEtransactionData({
 | 
			
		||||
        path: {
 | 
			
		||||
          // biome-ignore lint/style/useNamingConvention: api is in snake_case
 | 
			
		||||
          basket_id: basketId,
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
      if (res.response.ok) {
 | 
			
		||||
        this.data = res.data;
 | 
			
		||||
        this.isCbAvailable = true;
 | 
			
		||||
@@ -8,55 +8,55 @@ interface BasketItem {
 | 
			
		||||
  unit_price: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const BASKET_ITEMS_COOKIE_NAME: string = "basket_items";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Search for a cookie by name
 | 
			
		||||
 * @param name Name of the cookie to get
 | 
			
		||||
 * @returns the value of the cookie or null if it does not exist, undefined if not found
 | 
			
		||||
 */
 | 
			
		||||
function getCookie(name: string): string | null | undefined {
 | 
			
		||||
  if (!document.cookie || document.cookie.length === 0) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const found = document.cookie
 | 
			
		||||
    .split(";")
 | 
			
		||||
    .map((c) => c.trim())
 | 
			
		||||
    .find((c) => c.startsWith(`${name}=`));
 | 
			
		||||
 | 
			
		||||
  return found === undefined ? undefined : decodeURIComponent(found.split("=")[1]);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Fetch the basket items from the associated cookie
 | 
			
		||||
 * @returns the items in the basket
 | 
			
		||||
 */
 | 
			
		||||
function getStartingItems(): BasketItem[] {
 | 
			
		||||
  const cookie = getCookie(BASKET_ITEMS_COOKIE_NAME);
 | 
			
		||||
  if (!cookie) {
 | 
			
		||||
    return [];
 | 
			
		||||
  }
 | 
			
		||||
  // Django cookie backend converts `,` to `\054`
 | 
			
		||||
  let parsed = JSON.parse(cookie.replace(/\\054/g, ","));
 | 
			
		||||
  if (typeof parsed === "string") {
 | 
			
		||||
    // In some conditions, a second parsing is needed
 | 
			
		||||
    parsed = JSON.parse(parsed);
 | 
			
		||||
  }
 | 
			
		||||
  const res = Array.isArray(parsed) ? parsed : [];
 | 
			
		||||
  return res.filter((i) => !!document.getElementById(i.id));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
document.addEventListener("alpine:init", () => {
 | 
			
		||||
  Alpine.data("basket", () => ({
 | 
			
		||||
    items: getStartingItems() as BasketItem[],
 | 
			
		||||
  Alpine.data("basket", (lastPurchaseTime?: number) => ({
 | 
			
		||||
    basket: [] as BasketItem[],
 | 
			
		||||
 | 
			
		||||
    init() {
 | 
			
		||||
      this.basket = this.loadBasket();
 | 
			
		||||
      this.$watch("basket", () => {
 | 
			
		||||
        this.saveBasket();
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      // Invalidate basket if a purchase was made
 | 
			
		||||
      if (lastPurchaseTime !== null && localStorage.basketTimestamp !== undefined) {
 | 
			
		||||
        if (
 | 
			
		||||
          new Date(lastPurchaseTime) >=
 | 
			
		||||
          new Date(Number.parseInt(localStorage.basketTimestamp))
 | 
			
		||||
        ) {
 | 
			
		||||
          this.basket = [];
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // It's quite tricky to manually apply attributes to the management part
 | 
			
		||||
      // of a formset so we dynamically apply it here
 | 
			
		||||
      this.$refs.basketManagementForm
 | 
			
		||||
        .querySelector("#id_form-TOTAL_FORMS")
 | 
			
		||||
        .setAttribute(":value", "basket.length");
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    loadBasket(): BasketItem[] {
 | 
			
		||||
      if (localStorage.basket === undefined) {
 | 
			
		||||
        return [];
 | 
			
		||||
      }
 | 
			
		||||
      try {
 | 
			
		||||
        return JSON.parse(localStorage.basket);
 | 
			
		||||
      } catch (_err) {
 | 
			
		||||
        return [];
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    saveBasket() {
 | 
			
		||||
      localStorage.basket = JSON.stringify(this.basket);
 | 
			
		||||
      localStorage.basketTimestamp = Date.now();
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the total price of the basket
 | 
			
		||||
     * @returns {number} The total price of the basket
 | 
			
		||||
     */
 | 
			
		||||
    getTotal() {
 | 
			
		||||
      return this.items.reduce(
 | 
			
		||||
      return this.basket.reduce(
 | 
			
		||||
        (acc: number, item: BasketItem) => acc + item.quantity * item.unit_price,
 | 
			
		||||
        0,
 | 
			
		||||
      );
 | 
			
		||||
@@ -68,7 +68,6 @@ document.addEventListener("alpine:init", () => {
 | 
			
		||||
     */
 | 
			
		||||
    add(item: BasketItem) {
 | 
			
		||||
      item.quantity++;
 | 
			
		||||
      this.setCookies();
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@@ -76,39 +75,25 @@ document.addEventListener("alpine:init", () => {
 | 
			
		||||
     * @param itemId the id of the item to remove
 | 
			
		||||
     */
 | 
			
		||||
    remove(itemId: number) {
 | 
			
		||||
      const index = this.items.findIndex((e: BasketItem) => e.id === itemId);
 | 
			
		||||
      const index = this.basket.findIndex((e: BasketItem) => e.id === itemId);
 | 
			
		||||
 | 
			
		||||
      if (index < 0) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      this.items[index].quantity -= 1;
 | 
			
		||||
      this.basket[index].quantity -= 1;
 | 
			
		||||
 | 
			
		||||
      if (this.items[index].quantity === 0) {
 | 
			
		||||
        this.items = this.items.filter(
 | 
			
		||||
          (e: BasketItem) => e.id !== this.items[index].id,
 | 
			
		||||
      if (this.basket[index].quantity === 0) {
 | 
			
		||||
        this.basket = this.basket.filter(
 | 
			
		||||
          (e: BasketItem) => e.id !== this.basket[index].id,
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
      this.setCookies();
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Remove all the items from the basket & cleans the catalog CSS classes
 | 
			
		||||
     */
 | 
			
		||||
    clearBasket() {
 | 
			
		||||
      this.items = [];
 | 
			
		||||
      this.setCookies();
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Set the cookie in the browser with the basket items
 | 
			
		||||
     * ! the cookie survives an hour
 | 
			
		||||
     */
 | 
			
		||||
    setCookies() {
 | 
			
		||||
      if (this.items.length === 0) {
 | 
			
		||||
        document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=;Max-Age=0`;
 | 
			
		||||
      } else {
 | 
			
		||||
        document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(this.items))};Max-Age=3600`;
 | 
			
		||||
      }
 | 
			
		||||
      this.basket = [];
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@@ -127,7 +112,7 @@ document.addEventListener("alpine:init", () => {
 | 
			
		||||
        unit_price: price,
 | 
			
		||||
      } as BasketItem;
 | 
			
		||||
 | 
			
		||||
      this.items.push(newItem);
 | 
			
		||||
      this.basket.push(newItem);
 | 
			
		||||
      this.add(newItem);
 | 
			
		||||
 | 
			
		||||
      return newItem;
 | 
			
		||||
@@ -141,7 +126,7 @@ document.addEventListener("alpine:init", () => {
 | 
			
		||||
     * @param price The unit price of the product
 | 
			
		||||
     */
 | 
			
		||||
    addFromCatalog(id: number, name: string, price: number) {
 | 
			
		||||
      let item = this.items.find((e: BasketItem) => e.id === id);
 | 
			
		||||
      let item = this.basket.find((e: BasketItem) => e.id === id);
 | 
			
		||||
 | 
			
		||||
      // if the item is not in the basket, we create it
 | 
			
		||||
      // else we add + 1 to it
 | 
			
		||||
 
 | 
			
		||||
@@ -61,6 +61,7 @@
 | 
			
		||||
  word-break: break-word;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  line-height: 100%;
 | 
			
		||||
  white-space: normal;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#eboutic .fa-plus,
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block additional_js %}
 | 
			
		||||
  <script type="module" src="{{ static('bundled/eboutic/makecommand-index.ts') }}"></script>
 | 
			
		||||
  <script type="module" src="{{ static('bundled/eboutic/checkout-index.ts') }}"></script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
@@ -19,7 +19,7 @@
 | 
			
		||||
    let billingInfos = {{ billing_infos|safe }};
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <div x-data="etransaction(billingInfos)">
 | 
			
		||||
  <div x-data="etransaction(billingInfos, {{ basket.id }})">
 | 
			
		||||
    <p>{% trans %}Basket: {% endtrans %}</p>
 | 
			
		||||
    <table>
 | 
			
		||||
      <thead>
 | 
			
		||||
@@ -82,9 +82,8 @@
 | 
			
		||||
    {% elif basket.total > user.account_balance %}
 | 
			
		||||
      <p>{% trans %}AE account payment disabled because you do not have enough money remaining.{% endtrans %}</p>
 | 
			
		||||
    {% else %}
 | 
			
		||||
      <form method="post" action="{{ url('eboutic:pay_with_sith') }}" name="sith-pay-form">
 | 
			
		||||
      <form method="post" action="{{ url('eboutic:pay_with_sith', basket_id=basket.id) }}" name="sith-pay-form">
 | 
			
		||||
        {% csrf_token %}
 | 
			
		||||
        <input type="hidden" name="action" value="pay_with_sith_account">
 | 
			
		||||
        <input class="btn btn-blue" type="submit" value="{% trans %}Pay with Sith account{% endtrans %}"/>
 | 
			
		||||
      </form>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
@@ -21,57 +21,90 @@
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
  <h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1>
 | 
			
		||||
  <div id="eboutic" x-data="basket">
 | 
			
		||||
 | 
			
		||||
  {% if messages %}
 | 
			
		||||
    {% for message in messages %}
 | 
			
		||||
      <div class="alert alert-{{ message.tags }}">
 | 
			
		||||
        {{ message }}
 | 
			
		||||
      </div>
 | 
			
		||||
    {% endfor %}
 | 
			
		||||
  {% endif %}
 | 
			
		||||
 | 
			
		||||
  <div id="eboutic" x-data="basket({{ last_purchase_time }})">
 | 
			
		||||
    <div id="basket">
 | 
			
		||||
      <h3>Panier</h3>
 | 
			
		||||
      {% if errors %}
 | 
			
		||||
        <div class="alert alert-red">
 | 
			
		||||
          <div class="alert-main">
 | 
			
		||||
            {% for error in errors %}
 | 
			
		||||
              <p style="margin: 0">{{ error }}</p>
 | 
			
		||||
            {% endfor %}
 | 
			
		||||
          </div>
 | 
			
		||||
      <form method="post" action="">
 | 
			
		||||
        {% csrf_token %}
 | 
			
		||||
        <div x-ref="basketManagementForm">
 | 
			
		||||
          {{ form.management_form }}
 | 
			
		||||
        </div>
 | 
			
		||||
      {% endif %}
 | 
			
		||||
      <ul class="item-list">
 | 
			
		||||
                {# Starting money #}
 | 
			
		||||
        <li>
 | 
			
		||||
          <span class="item-name">
 | 
			
		||||
            <strong>{% trans %}Current account amount: {% endtrans %}</strong>
 | 
			
		||||
          </span>
 | 
			
		||||
          <span class="item-price">
 | 
			
		||||
            <strong>{{ "%0.2f"|format(customer_amount) }} €</strong>
 | 
			
		||||
          </span>
 | 
			
		||||
        </li>
 | 
			
		||||
        <template x-for="item in items" :key="item.id">
 | 
			
		||||
          <li class="item-row" x-show="item.quantity > 0">
 | 
			
		||||
            <div class="item-quantity">
 | 
			
		||||
              <i class="fa fa-minus fa-xs" @click="remove(item.id)"></i>
 | 
			
		||||
              <span x-text="item.quantity"></span>
 | 
			
		||||
              <i class="fa fa-plus" @click="add(item)"></i>
 | 
			
		||||
 | 
			
		||||
        {% if form.non_form_errors() or form.errors %}
 | 
			
		||||
          <div class="alert alert-red">
 | 
			
		||||
            <div class="alert-main">
 | 
			
		||||
              {% for error in form.non_form_errors() + form.errors %}
 | 
			
		||||
                <p style="margin: 0">{{ error }}</p>
 | 
			
		||||
              {% endfor %}
 | 
			
		||||
            </div>
 | 
			
		||||
            <span class="item-name" x-text="item.name"></span>
 | 
			
		||||
            <span class="item-price" x-text="(item.unit_price * item.quantity).toFixed(2) + ' €'"></span>
 | 
			
		||||
          </div>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        <ul class="item-list">
 | 
			
		||||
                  {# Starting money #}
 | 
			
		||||
          <li>
 | 
			
		||||
            <span class="item-name">
 | 
			
		||||
              <strong>{% trans %}Current account amount: {% endtrans %}</strong>
 | 
			
		||||
            </span>
 | 
			
		||||
            <span class="item-price">
 | 
			
		||||
              <strong>{{ "%0.2f"|format(customer_amount) }} €</strong>
 | 
			
		||||
            </span>
 | 
			
		||||
          </li>
 | 
			
		||||
        </template>
 | 
			
		||||
                {# Total price #}
 | 
			
		||||
        <li style="margin-top: 20px">
 | 
			
		||||
          <span class="item-name"><strong>{% trans %}Basket amount: {% endtrans %}</strong></span>
 | 
			
		||||
          <span x-text="getTotal().toFixed(2) + ' €'" class="item-price"></span>
 | 
			
		||||
        </li>
 | 
			
		||||
      </ul>
 | 
			
		||||
      <div class="catalog-buttons">
 | 
			
		||||
        <button @click="clearBasket()" class="btn btn-grey">
 | 
			
		||||
          <i class="fa fa-trash"></i>
 | 
			
		||||
          {% trans %}Clear{% endtrans %}
 | 
			
		||||
        </button>
 | 
			
		||||
        <form method="get" action="{{ url('eboutic:command') }}">
 | 
			
		||||
 | 
			
		||||
          <template x-for="(item, index) in Object.values(basket)" :key="item.id">
 | 
			
		||||
            <li class="item-row" x-show="item.quantity > 0">
 | 
			
		||||
              <div class="item-quantity">
 | 
			
		||||
                <i class="fa fa-minus fa-xs" @click="remove(item.id)"></i>
 | 
			
		||||
                <span x-text="item.quantity"></span>
 | 
			
		||||
                <i class="fa fa-plus" @click="add(item)"></i>
 | 
			
		||||
              </div>
 | 
			
		||||
              <span class="item-name" x-text="item.name"></span>
 | 
			
		||||
              <span class="item-price" x-text="(item.unit_price * item.quantity).toFixed(2) + ' €'"></span>
 | 
			
		||||
 | 
			
		||||
              <input
 | 
			
		||||
                type="hidden"
 | 
			
		||||
                :value="item.quantity"
 | 
			
		||||
                :id="`id_form-${index}-quantity`"
 | 
			
		||||
                :name="`form-${index}-quantity`"
 | 
			
		||||
                required
 | 
			
		||||
                readonly
 | 
			
		||||
              >
 | 
			
		||||
              <input
 | 
			
		||||
                type="hidden"
 | 
			
		||||
                :value="item.id"
 | 
			
		||||
                :id="`id_form-${index}-id`"
 | 
			
		||||
                :name="`form-${index}-id`"
 | 
			
		||||
                required
 | 
			
		||||
                readonly
 | 
			
		||||
              >
 | 
			
		||||
 | 
			
		||||
            </li>
 | 
			
		||||
          </template>
 | 
			
		||||
                  {# Total price #}
 | 
			
		||||
          <li style="margin-top: 20px">
 | 
			
		||||
            <span class="item-name"><strong>{% trans %}Basket amount: {% endtrans %}</strong></span>
 | 
			
		||||
            <span x-text="getTotal().toFixed(2) + ' €'" class="item-price"></span>
 | 
			
		||||
          </li>
 | 
			
		||||
        </ul>
 | 
			
		||||
        <div class="catalog-buttons">
 | 
			
		||||
          <button @click.prevent="clearBasket()" class="btn btn-grey">
 | 
			
		||||
            <i class="fa fa-trash"></i>
 | 
			
		||||
            {% trans %}Clear{% endtrans %}
 | 
			
		||||
          </button>
 | 
			
		||||
          <button class="btn btn-blue">
 | 
			
		||||
            <i class="fa fa-check"></i>
 | 
			
		||||
            <input type="submit" value="{% trans %}Validate{% endtrans %}"/>
 | 
			
		||||
          </button>
 | 
			
		||||
        </form>
 | 
			
		||||
      </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </form>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div id="catalog">
 | 
			
		||||
      {% if not request.user.date_of_birth %}
 | 
			
		||||
@@ -108,7 +141,7 @@
 | 
			
		||||
                {% trans trimmed %}
 | 
			
		||||
                  Our partner uses Weezevent to sell tickets.
 | 
			
		||||
                  Weezevent may collect user info according to
 | 
			
		||||
                  it's own privacy policy.
 | 
			
		||||
                  its own privacy policy.
 | 
			
		||||
                  By clicking the accept button you consent to
 | 
			
		||||
                  their terms of services.
 | 
			
		||||
                {% endtrans %}
 | 
			
		||||
@@ -158,7 +191,7 @@
 | 
			
		||||
                  <button
 | 
			
		||||
                    id="{{ p.id }}"
 | 
			
		||||
                    class="card product-button clickable shadow"
 | 
			
		||||
                    :class="{selected: items.some((i) => i.id === {{ p.id }})}"
 | 
			
		||||
                    :class="{selected: basket.some((i) => i.id === {{ p.id }})}"
 | 
			
		||||
                    @click='addFromCatalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'
 | 
			
		||||
                  >
 | 
			
		||||
                    {% if p.icon %}
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,14 @@
 | 
			
		||||
  <h3>{% trans %}Eboutic{% endtrans %}</h3>
 | 
			
		||||
 | 
			
		||||
  <div>
 | 
			
		||||
    {% if messages %}
 | 
			
		||||
      {% for message in messages %}
 | 
			
		||||
        <div class="alert alert-{{ message.tags }}">
 | 
			
		||||
          {{ message }}
 | 
			
		||||
        </div>
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
    {% endif %}
 | 
			
		||||
 | 
			
		||||
    {% if success %}
 | 
			
		||||
      {% trans %}Payment successful{% endtrans %}
 | 
			
		||||
    {% else %}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										179
									
								
								eboutic/tests/test_basket.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								eboutic/tests/test_basket.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,179 @@
 | 
			
		||||
import pytest
 | 
			
		||||
from django.http import HttpResponse
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
from django.test.client import Client
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from django.utils.timezone import localdate
 | 
			
		||||
from model_bakery import baker
 | 
			
		||||
from pytest_django.asserts import assertRedirects
 | 
			
		||||
 | 
			
		||||
from core.baker_recipes import subscriber_user
 | 
			
		||||
from core.models import Group, User
 | 
			
		||||
from counter.baker_recipes import product_recipe
 | 
			
		||||
from counter.models import Counter, ProductType, get_eboutic
 | 
			
		||||
from counter.tests.test_counter import BasketItem
 | 
			
		||||
from eboutic.models import Basket
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.django_db
 | 
			
		||||
def test_get_eboutic():
 | 
			
		||||
    assert Counter.objects.get(name="Eboutic") == get_eboutic()
 | 
			
		||||
 | 
			
		||||
    baker.make(Counter, type="EBOUTIC")
 | 
			
		||||
 | 
			
		||||
    assert Counter.objects.get(name="Eboutic") == get_eboutic()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestEboutic(TestCase):
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def setUpTestData(cls):
 | 
			
		||||
        cls.group_cotiz = baker.make(Group)
 | 
			
		||||
        cls.group_public = baker.make(Group)
 | 
			
		||||
 | 
			
		||||
        cls.new_customer = baker.make(User)
 | 
			
		||||
        cls.new_customer_adult = baker.make(User)
 | 
			
		||||
        cls.subscriber = subscriber_user.make()
 | 
			
		||||
 | 
			
		||||
        cls.set_age(cls.new_customer, 5)
 | 
			
		||||
        cls.set_age(cls.new_customer_adult, 20)
 | 
			
		||||
        cls.set_age(cls.subscriber, 20)
 | 
			
		||||
 | 
			
		||||
        product_type = baker.make(ProductType)
 | 
			
		||||
 | 
			
		||||
        cls.snack = product_recipe.make(
 | 
			
		||||
            selling_price=1.5, special_selling_price=1, product_type=product_type
 | 
			
		||||
        )
 | 
			
		||||
        cls.beer = product_recipe.make(
 | 
			
		||||
            limit_age=18,
 | 
			
		||||
            selling_price=2.5,
 | 
			
		||||
            special_selling_price=1,
 | 
			
		||||
            product_type=product_type,
 | 
			
		||||
        )
 | 
			
		||||
        cls.not_in_counter = product_recipe.make(
 | 
			
		||||
            selling_price=3.5, product_type=product_type
 | 
			
		||||
        )
 | 
			
		||||
        cls.cotiz = product_recipe.make(selling_price=10, product_type=product_type)
 | 
			
		||||
 | 
			
		||||
        cls.group_public.products.add(cls.snack, cls.beer, cls.not_in_counter)
 | 
			
		||||
        cls.group_cotiz.products.add(cls.cotiz)
 | 
			
		||||
 | 
			
		||||
        cls.subscriber.groups.add(cls.group_cotiz, cls.group_public)
 | 
			
		||||
        cls.new_customer.groups.add(cls.group_public)
 | 
			
		||||
        cls.new_customer_adult.groups.add(cls.group_public)
 | 
			
		||||
 | 
			
		||||
        cls.eboutic = get_eboutic()
 | 
			
		||||
        cls.eboutic.products.add(cls.cotiz, cls.beer, cls.snack)
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def set_age(cls, user: User, age: int):
 | 
			
		||||
        user.date_of_birth = localdate().replace(year=localdate().year - age)
 | 
			
		||||
        user.save()
 | 
			
		||||
 | 
			
		||||
    def submit_basket(
 | 
			
		||||
        self, basket: list[BasketItem], client: Client | None = None
 | 
			
		||||
    ) -> HttpResponse:
 | 
			
		||||
        used_client: Client = client if client is not None else self.client
 | 
			
		||||
        data = {
 | 
			
		||||
            "form-TOTAL_FORMS": str(len(basket)),
 | 
			
		||||
            "form-INITIAL_FORMS": "0",
 | 
			
		||||
        }
 | 
			
		||||
        for index, item in enumerate(basket):
 | 
			
		||||
            data.update(item.to_form(index))
 | 
			
		||||
        return used_client.post(reverse("eboutic:main"), data)
 | 
			
		||||
 | 
			
		||||
    def test_submit_empty_basket(self):
 | 
			
		||||
        self.client.force_login(self.subscriber)
 | 
			
		||||
        for basket in [
 | 
			
		||||
            [],
 | 
			
		||||
            [BasketItem(None, None)],
 | 
			
		||||
            [BasketItem(None, None), BasketItem(None, None)],
 | 
			
		||||
        ]:
 | 
			
		||||
            response = self.submit_basket(basket)
 | 
			
		||||
            assert response.status_code == 200
 | 
			
		||||
            assert "Votre panier est vide" in response.text
 | 
			
		||||
 | 
			
		||||
    def test_submit_invalid_basket(self):
 | 
			
		||||
        self.client.force_login(self.subscriber)
 | 
			
		||||
        for item in [
 | 
			
		||||
            BasketItem(-1, 2),
 | 
			
		||||
            BasketItem(self.snack.id, -1),
 | 
			
		||||
            BasketItem(None, 1),
 | 
			
		||||
            BasketItem(self.snack.id, None),
 | 
			
		||||
        ]:
 | 
			
		||||
            response = self.submit_basket([item])
 | 
			
		||||
            assert response.status_code == 200
 | 
			
		||||
 | 
			
		||||
    def test_anonymous(self):
 | 
			
		||||
        assertRedirects(
 | 
			
		||||
            self.client.get(reverse("eboutic:main")),
 | 
			
		||||
            reverse("core:login", query={"next": reverse("eboutic:main")}),
 | 
			
		||||
        )
 | 
			
		||||
        assertRedirects(
 | 
			
		||||
            self.submit_basket([]),
 | 
			
		||||
            reverse("core:login", query={"next": reverse("eboutic:main")}),
 | 
			
		||||
        )
 | 
			
		||||
        assertRedirects(
 | 
			
		||||
            self.submit_basket([BasketItem(self.snack.id, 1)]),
 | 
			
		||||
            reverse("core:login", query={"next": reverse("eboutic:main")}),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_add_forbidden_product(self):
 | 
			
		||||
        self.client.force_login(self.new_customer)
 | 
			
		||||
        response = self.submit_basket([BasketItem(self.beer.id, 1)])
 | 
			
		||||
        assert response.status_code == 200
 | 
			
		||||
        assert Basket.objects.first() is None
 | 
			
		||||
 | 
			
		||||
        response = self.submit_basket([BasketItem(self.cotiz.id, 1)])
 | 
			
		||||
        assert response.status_code == 200
 | 
			
		||||
        assert Basket.objects.first() is None
 | 
			
		||||
 | 
			
		||||
        response = self.submit_basket([BasketItem(self.not_in_counter.id, 1)])
 | 
			
		||||
        assert response.status_code == 200
 | 
			
		||||
        assert Basket.objects.first() is None
 | 
			
		||||
 | 
			
		||||
        self.client.force_login(self.new_customer)
 | 
			
		||||
        response = self.submit_basket([BasketItem(self.cotiz.id, 1)])
 | 
			
		||||
        assert response.status_code == 200
 | 
			
		||||
        assert Basket.objects.first() is None
 | 
			
		||||
 | 
			
		||||
        response = self.submit_basket([BasketItem(self.not_in_counter.id, 1)])
 | 
			
		||||
        assert response.status_code == 200
 | 
			
		||||
        assert Basket.objects.first() is None
 | 
			
		||||
 | 
			
		||||
    def test_create_basket(self):
 | 
			
		||||
        self.client.force_login(self.new_customer)
 | 
			
		||||
        assertRedirects(
 | 
			
		||||
            self.submit_basket([BasketItem(self.snack.id, 2)]),
 | 
			
		||||
            reverse("eboutic:checkout", kwargs={"basket_id": 1}),
 | 
			
		||||
        )
 | 
			
		||||
        assert Basket.objects.get(id=1).total == self.snack.selling_price * 2
 | 
			
		||||
 | 
			
		||||
        self.client.force_login(self.new_customer_adult)
 | 
			
		||||
        assertRedirects(
 | 
			
		||||
            self.submit_basket(
 | 
			
		||||
                [BasketItem(self.snack.id, 2), BasketItem(self.beer.id, 1)]
 | 
			
		||||
            ),
 | 
			
		||||
            reverse("eboutic:checkout", kwargs={"basket_id": 2}),
 | 
			
		||||
        )
 | 
			
		||||
        assert (
 | 
			
		||||
            Basket.objects.get(id=2).total
 | 
			
		||||
            == self.snack.selling_price * 2 + self.beer.selling_price
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.client.force_login(self.subscriber)
 | 
			
		||||
        assertRedirects(
 | 
			
		||||
            self.submit_basket(
 | 
			
		||||
                [
 | 
			
		||||
                    BasketItem(self.snack.id, 2),
 | 
			
		||||
                    BasketItem(self.beer.id, 1),
 | 
			
		||||
                    BasketItem(self.cotiz.id, 1),
 | 
			
		||||
                ]
 | 
			
		||||
            ),
 | 
			
		||||
            reverse("eboutic:checkout", kwargs={"basket_id": 3}),
 | 
			
		||||
        )
 | 
			
		||||
        assert (
 | 
			
		||||
            Basket.objects.get(id=3).total
 | 
			
		||||
            == self.snack.selling_price * 2
 | 
			
		||||
            + self.beer.selling_price
 | 
			
		||||
            + self.cotiz.selling_price
 | 
			
		||||
        )
 | 
			
		||||
							
								
								
									
										269
									
								
								eboutic/tests/test_payment.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										269
									
								
								eboutic/tests/test_payment.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,269 @@
 | 
			
		||||
import base64
 | 
			
		||||
import urllib
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
from typing import TYPE_CHECKING
 | 
			
		||||
 | 
			
		||||
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
 | 
			
		||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
 | 
			
		||||
from cryptography.hazmat.primitives.hashes import SHA1
 | 
			
		||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.messages import get_messages
 | 
			
		||||
from django.contrib.messages.constants import DEFAULT_LEVELS
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from model_bakery import baker
 | 
			
		||||
from pytest_django.asserts import assertRedirects
 | 
			
		||||
 | 
			
		||||
from core.baker_recipes import old_subscriber_user, subscriber_user
 | 
			
		||||
from counter.baker_recipes import product_recipe
 | 
			
		||||
from counter.models import Product, ProductType, Selling
 | 
			
		||||
from counter.tests.test_counter import force_refill_user
 | 
			
		||||
from eboutic.models import Basket, BasketItem
 | 
			
		||||
 | 
			
		||||
if TYPE_CHECKING:
 | 
			
		||||
    from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestPaymentBase(TestCase):
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def setUpTestData(cls):
 | 
			
		||||
        cls.customer = subscriber_user.make()
 | 
			
		||||
        cls.basket = baker.make(Basket, user=cls.customer)
 | 
			
		||||
        cls.refilling = product_recipe.make(
 | 
			
		||||
            product_type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING,
 | 
			
		||||
            selling_price=15,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        product_type = baker.make(ProductType)
 | 
			
		||||
 | 
			
		||||
        cls.snack = product_recipe.make(
 | 
			
		||||
            selling_price=1.5, special_selling_price=1, product_type=product_type
 | 
			
		||||
        )
 | 
			
		||||
        cls.beer = product_recipe.make(
 | 
			
		||||
            limit_age=18,
 | 
			
		||||
            selling_price=2.5,
 | 
			
		||||
            special_selling_price=1,
 | 
			
		||||
            product_type=product_type,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        BasketItem.from_product(cls.snack, 1, cls.basket).save()
 | 
			
		||||
        BasketItem.from_product(cls.beer, 2, cls.basket).save()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestPaymentSith(TestPaymentBase):
 | 
			
		||||
    def test_anonymous(self):
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
            reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id})
 | 
			
		||||
        )
 | 
			
		||||
        assert response.status_code == 403
 | 
			
		||||
        assert Basket.objects.contains(self.basket), (
 | 
			
		||||
            "After an unsuccessful request, the basket should be kept"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_unauthorized(self):
 | 
			
		||||
        self.client.force_login(subscriber_user.make())
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
            reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id})
 | 
			
		||||
        )
 | 
			
		||||
        assert response.status_code == 403
 | 
			
		||||
        assert Basket.objects.contains(self.basket), (
 | 
			
		||||
            "After an unsuccessful request, the basket should be kept"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_not_found(self):
 | 
			
		||||
        self.client.force_login(self.customer)
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
            reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id + 1})
 | 
			
		||||
        )
 | 
			
		||||
        assert response.status_code == 404
 | 
			
		||||
        assert Basket.objects.contains(self.basket), (
 | 
			
		||||
            "After an unsuccessful request, the basket should be kept"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_only_post_allowed(self):
 | 
			
		||||
        self.client.force_login(self.customer)
 | 
			
		||||
        force_refill_user(self.customer, self.basket.total + 1)
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
            reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id})
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        assert response.status_code == 405
 | 
			
		||||
 | 
			
		||||
        assert Basket.objects.contains(self.basket), (
 | 
			
		||||
            "After an unsuccessful request, the basket should be kept"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.customer.customer.refresh_from_db()
 | 
			
		||||
        assert self.customer.customer.amount == self.basket.total + 1
 | 
			
		||||
 | 
			
		||||
    def test_buy_success(self):
 | 
			
		||||
        self.client.force_login(self.customer)
 | 
			
		||||
        force_refill_user(self.customer, self.basket.total + 1)
 | 
			
		||||
        assertRedirects(
 | 
			
		||||
            self.client.post(
 | 
			
		||||
                reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id}),
 | 
			
		||||
            ),
 | 
			
		||||
            reverse("eboutic:payment_result", kwargs={"result": "success"}),
 | 
			
		||||
        )
 | 
			
		||||
        assert Basket.objects.filter(id=self.basket.id).first() is None
 | 
			
		||||
        self.customer.customer.refresh_from_db()
 | 
			
		||||
        assert self.customer.customer.amount == Decimal("1")
 | 
			
		||||
 | 
			
		||||
        sellings = Selling.objects.filter(customer=self.customer.customer).order_by(
 | 
			
		||||
            "quantity"
 | 
			
		||||
        )
 | 
			
		||||
        assert len(sellings) == 2
 | 
			
		||||
        assert sellings[0].payment_method == "SITH_ACCOUNT"
 | 
			
		||||
        assert sellings[0].quantity == 1
 | 
			
		||||
        assert sellings[0].unit_price == self.snack.selling_price
 | 
			
		||||
        assert sellings[0].counter.type == "EBOUTIC"
 | 
			
		||||
        assert sellings[0].product == self.snack
 | 
			
		||||
 | 
			
		||||
        assert sellings[1].payment_method == "SITH_ACCOUNT"
 | 
			
		||||
        assert sellings[1].quantity == 2
 | 
			
		||||
        assert sellings[1].unit_price == self.beer.selling_price
 | 
			
		||||
        assert sellings[1].counter.type == "EBOUTIC"
 | 
			
		||||
        assert sellings[1].product == self.beer
 | 
			
		||||
 | 
			
		||||
    def test_not_enough_money(self):
 | 
			
		||||
        self.client.force_login(self.customer)
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
            reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id})
 | 
			
		||||
        )
 | 
			
		||||
        assertRedirects(
 | 
			
		||||
            response,
 | 
			
		||||
            reverse("eboutic:payment_result", kwargs={"result": "failure"}),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        messages = list(get_messages(response.wsgi_request))
 | 
			
		||||
        assert len(messages) == 1
 | 
			
		||||
        assert messages[0].level == DEFAULT_LEVELS["ERROR"]
 | 
			
		||||
        assert messages[0].message == "Solde insuffisant"
 | 
			
		||||
 | 
			
		||||
        assert Basket.objects.contains(self.basket), (
 | 
			
		||||
            "After an unsuccessful request, the basket should be kept"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_refilling_in_basket(self):
 | 
			
		||||
        BasketItem.from_product(self.refilling, 1, self.basket).save()
 | 
			
		||||
        self.client.force_login(self.customer)
 | 
			
		||||
        force_refill_user(self.customer, self.basket.total + 1)
 | 
			
		||||
        self.customer.customer.refresh_from_db()
 | 
			
		||||
        initial_account_balance = self.customer.customer.amount
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
            reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id})
 | 
			
		||||
        )
 | 
			
		||||
        assertRedirects(
 | 
			
		||||
            response,
 | 
			
		||||
            reverse("eboutic:payment_result", kwargs={"result": "failure"}),
 | 
			
		||||
        )
 | 
			
		||||
        assert Basket.objects.filter(id=self.basket.id).first() is not None
 | 
			
		||||
        messages = list(get_messages(response.wsgi_request))
 | 
			
		||||
        assert messages[0].level == DEFAULT_LEVELS["ERROR"]
 | 
			
		||||
        assert (
 | 
			
		||||
            messages[0].message
 | 
			
		||||
            == "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith"
 | 
			
		||||
        )
 | 
			
		||||
        self.customer.customer.refresh_from_db()
 | 
			
		||||
        assert self.customer.customer.amount == initial_account_balance
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestPaymentCard(TestPaymentBase):
 | 
			
		||||
    def generate_bank_valid_answer(self, basket: Basket):
 | 
			
		||||
        query = (
 | 
			
		||||
            f"Amount={int(basket.total * 100)}&BasketID={basket.id}&Auto=42&Error=00000"
 | 
			
		||||
        )
 | 
			
		||||
        with open("./eboutic/tests/private_key.pem", "br") as f:
 | 
			
		||||
            PRIVKEY = f.read()
 | 
			
		||||
        with open("./eboutic/tests/public_key.pem") as f:
 | 
			
		||||
            settings.SITH_EBOUTIC_PUB_KEY = f.read()
 | 
			
		||||
        key: RSAPrivateKey = load_pem_private_key(PRIVKEY, None)
 | 
			
		||||
        sig = key.sign(query.encode("utf-8"), PKCS1v15(), SHA1())
 | 
			
		||||
        b64sig = base64.b64encode(sig).decode("ascii")
 | 
			
		||||
 | 
			
		||||
        url = reverse("eboutic:etransation_autoanswer") + "?%s&Sig=%s" % (
 | 
			
		||||
            query,
 | 
			
		||||
            urllib.parse.quote_plus(b64sig),
 | 
			
		||||
        )
 | 
			
		||||
        return url
 | 
			
		||||
 | 
			
		||||
    def test_buy_success(self):
 | 
			
		||||
        response = self.client.get(self.generate_bank_valid_answer(self.basket))
 | 
			
		||||
        assert response.status_code == 200
 | 
			
		||||
        assert response.content.decode("utf-8") == "Payment successful"
 | 
			
		||||
        assert Basket.objects.filter(id=self.basket.id).first() is None
 | 
			
		||||
 | 
			
		||||
        sellings = Selling.objects.filter(customer=self.customer.customer).order_by(
 | 
			
		||||
            "quantity"
 | 
			
		||||
        )
 | 
			
		||||
        assert len(sellings) == 2
 | 
			
		||||
        assert sellings[0].payment_method == "CARD"
 | 
			
		||||
        assert sellings[0].quantity == 1
 | 
			
		||||
        assert sellings[0].unit_price == self.snack.selling_price
 | 
			
		||||
        assert sellings[0].counter.type == "EBOUTIC"
 | 
			
		||||
        assert sellings[0].product == self.snack
 | 
			
		||||
 | 
			
		||||
        assert sellings[1].payment_method == "CARD"
 | 
			
		||||
        assert sellings[1].quantity == 2
 | 
			
		||||
        assert sellings[1].unit_price == self.beer.selling_price
 | 
			
		||||
        assert sellings[1].counter.type == "EBOUTIC"
 | 
			
		||||
        assert sellings[1].product == self.beer
 | 
			
		||||
 | 
			
		||||
    def test_buy_subscribe_product(self):
 | 
			
		||||
        customer = old_subscriber_user.make()
 | 
			
		||||
        assert customer.subscriptions.count() == 1
 | 
			
		||||
        assert not customer.subscriptions.first().is_valid_now()
 | 
			
		||||
 | 
			
		||||
        basket = baker.make(Basket, user=customer)
 | 
			
		||||
        BasketItem.from_product(Product.objects.get(code="2SCOTIZ"), 1, basket).save()
 | 
			
		||||
        response = self.client.get(self.generate_bank_valid_answer(basket))
 | 
			
		||||
        assert response.status_code == 200
 | 
			
		||||
 | 
			
		||||
        assert customer.subscriptions.count() == 2
 | 
			
		||||
 | 
			
		||||
        subscription = customer.subscriptions.order_by("-subscription_end").first()
 | 
			
		||||
        assert subscription.is_valid_now()
 | 
			
		||||
        assert subscription.subscription_type == "deux-semestres"
 | 
			
		||||
        assert subscription.location == "EBOUTIC"
 | 
			
		||||
 | 
			
		||||
    def test_buy_refilling(self):
 | 
			
		||||
        BasketItem.from_product(self.refilling, 2, self.basket).save()
 | 
			
		||||
        response = self.client.get(self.generate_bank_valid_answer(self.basket))
 | 
			
		||||
        assert response.status_code == 200
 | 
			
		||||
 | 
			
		||||
        self.customer.customer.refresh_from_db()
 | 
			
		||||
        assert self.customer.customer.amount == self.refilling.selling_price * 2
 | 
			
		||||
 | 
			
		||||
    def test_multiple_responses(self):
 | 
			
		||||
        bank_response = self.generate_bank_valid_answer(self.basket)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(bank_response)
 | 
			
		||||
        assert response.status_code == 200
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(bank_response)
 | 
			
		||||
        assert response.status_code == 500
 | 
			
		||||
        assert (
 | 
			
		||||
            response.text
 | 
			
		||||
            == "Basket processing failed with error: SuspiciousOperation('Basket does not exists')"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_unknown_basket(self):
 | 
			
		||||
        bank_response = self.generate_bank_valid_answer(self.basket)
 | 
			
		||||
        self.basket.delete()
 | 
			
		||||
        response = self.client.get(bank_response)
 | 
			
		||||
        assert response.status_code == 500
 | 
			
		||||
        assert (
 | 
			
		||||
            response.text
 | 
			
		||||
            == "Basket processing failed with error: SuspiciousOperation('Basket does not exists')"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_altered_basket(self):
 | 
			
		||||
        bank_response = self.generate_bank_valid_answer(self.basket)
 | 
			
		||||
        BasketItem.from_product(self.snack, 1, self.basket).save()
 | 
			
		||||
        response = self.client.get(bank_response)
 | 
			
		||||
        assert response.status_code == 500
 | 
			
		||||
        assert (
 | 
			
		||||
            response.text == "Basket processing failed with error: "
 | 
			
		||||
            "SuspiciousOperation('Basket total and amount do not match')"
 | 
			
		||||
        )
 | 
			
		||||
@@ -1,254 +0,0 @@
 | 
			
		||||
#
 | 
			
		||||
# Copyright 2016,2017
 | 
			
		||||
# - Skia <skia@libskia.so>
 | 
			
		||||
# - Maréchal <thgirod@hotmail.com
 | 
			
		||||
#
 | 
			
		||||
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
 | 
			
		||||
# http://ae.utbm.fr.
 | 
			
		||||
#
 | 
			
		||||
# This program is free software; you can redistribute it and/or modify it under
 | 
			
		||||
# the terms of the GNU General Public License a published by the Free Software
 | 
			
		||||
# Foundation; either version 3 of the License, or (at your option) any later
 | 
			
		||||
# version.
 | 
			
		||||
#
 | 
			
		||||
# This program is distributed in the hope that it will be useful, but WITHOUT
 | 
			
		||||
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 | 
			
		||||
# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
 | 
			
		||||
# details.
 | 
			
		||||
#
 | 
			
		||||
# You should have received a copy of the GNU General Public License along with
 | 
			
		||||
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
 | 
			
		||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
 | 
			
		||||
#
 | 
			
		||||
#
 | 
			
		||||
import base64
 | 
			
		||||
import json
 | 
			
		||||
import urllib
 | 
			
		||||
from typing import TYPE_CHECKING
 | 
			
		||||
 | 
			
		||||
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
 | 
			
		||||
from cryptography.hazmat.primitives.hashes import SHA1
 | 
			
		||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db.models import Max
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
 | 
			
		||||
from core.models import User
 | 
			
		||||
from counter.models import Counter, Customer, Product, Selling
 | 
			
		||||
from eboutic.models import Basket, BasketItem
 | 
			
		||||
 | 
			
		||||
if TYPE_CHECKING:
 | 
			
		||||
    from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestEboutic(TestCase):
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def setUpTestData(cls):
 | 
			
		||||
        cls.barbar = Product.objects.get(code="BARB")
 | 
			
		||||
        cls.refill = Product.objects.get(code="15REFILL")
 | 
			
		||||
        cls.cotis = Product.objects.get(code="1SCOTIZ")
 | 
			
		||||
        cls.eboutic = Counter.objects.get(name="Eboutic")
 | 
			
		||||
        cls.skia = User.objects.get(username="skia")
 | 
			
		||||
        cls.subscriber = User.objects.get(username="subscriber")
 | 
			
		||||
        cls.old_subscriber = User.objects.get(username="old_subscriber")
 | 
			
		||||
        cls.public = User.objects.get(username="public")
 | 
			
		||||
 | 
			
		||||
    def get_busy_basket(self, user) -> Basket:
 | 
			
		||||
        """Create and return a basket with 3 barbar and 1 cotis in it.
 | 
			
		||||
 | 
			
		||||
        Edit the client session to store the basket id in it.
 | 
			
		||||
        """
 | 
			
		||||
        session = self.client.session
 | 
			
		||||
        basket = Basket.objects.create(user=user)
 | 
			
		||||
        session["basket_id"] = basket.id
 | 
			
		||||
        session.save()
 | 
			
		||||
        BasketItem.from_product(self.barbar, 3, basket).save()
 | 
			
		||||
        BasketItem.from_product(self.cotis, 1, basket).save()
 | 
			
		||||
        return basket
 | 
			
		||||
 | 
			
		||||
    def generate_bank_valid_answer(self) -> str:
 | 
			
		||||
        basket = Basket.from_session(self.client.session)
 | 
			
		||||
        basket_id = basket.id
 | 
			
		||||
        amount = int(basket.total * 100)
 | 
			
		||||
        query = f"Amount={amount}&BasketID={basket_id}&Auto=42&Error=00000"
 | 
			
		||||
        with open("./eboutic/tests/private_key.pem", "br") as f:
 | 
			
		||||
            PRIVKEY = f.read()
 | 
			
		||||
        with open("./eboutic/tests/public_key.pem") as f:
 | 
			
		||||
            settings.SITH_EBOUTIC_PUB_KEY = f.read()
 | 
			
		||||
        key: RSAPrivateKey = load_pem_private_key(PRIVKEY, None)
 | 
			
		||||
        sig = key.sign(query.encode("utf-8"), PKCS1v15(), SHA1())
 | 
			
		||||
        b64sig = base64.b64encode(sig).decode("ascii")
 | 
			
		||||
 | 
			
		||||
        url = reverse("eboutic:etransation_autoanswer") + "?%s&Sig=%s" % (
 | 
			
		||||
            query,
 | 
			
		||||
            urllib.parse.quote_plus(b64sig),
 | 
			
		||||
        )
 | 
			
		||||
        return url
 | 
			
		||||
 | 
			
		||||
    def test_buy_with_sith_account(self):
 | 
			
		||||
        self.client.force_login(self.subscriber)
 | 
			
		||||
        self.subscriber.customer.amount = 100  # give money before test
 | 
			
		||||
        self.subscriber.customer.save()
 | 
			
		||||
        basket = self.get_busy_basket(self.subscriber)
 | 
			
		||||
        amount = basket.total
 | 
			
		||||
        response = self.client.post(reverse("eboutic:pay_with_sith"))
 | 
			
		||||
        self.assertRedirects(response, "/eboutic/pay/success/")
 | 
			
		||||
        new_balance = Customer.objects.get(user=self.subscriber).amount
 | 
			
		||||
        assert float(new_balance) == 100 - amount
 | 
			
		||||
        expected = 'basket_items=""; expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/eboutic'
 | 
			
		||||
        assert expected == self.client.cookies["basket_items"].OutputString()
 | 
			
		||||
 | 
			
		||||
    def test_buy_with_sith_account_no_money(self):
 | 
			
		||||
        self.client.force_login(self.subscriber)
 | 
			
		||||
        basket = self.get_busy_basket(self.subscriber)
 | 
			
		||||
        initial = basket.total - 1  # just not enough to complete the sale
 | 
			
		||||
        self.subscriber.customer.amount = initial
 | 
			
		||||
        self.subscriber.customer.save()
 | 
			
		||||
        response = self.client.post(reverse("eboutic:pay_with_sith"))
 | 
			
		||||
        self.assertRedirects(response, "/eboutic/pay/failure/")
 | 
			
		||||
        new_balance = Customer.objects.get(user=self.subscriber).amount
 | 
			
		||||
        assert float(new_balance) == initial
 | 
			
		||||
        # this cookie should be removed after payment
 | 
			
		||||
        expected = 'basket_items=""; expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/eboutic'
 | 
			
		||||
        assert expected == self.client.cookies["basket_items"].OutputString()
 | 
			
		||||
 | 
			
		||||
    def test_submit_basket(self):
 | 
			
		||||
        self.client.force_login(self.subscriber)
 | 
			
		||||
        self.client.cookies["basket_items"] = """[
 | 
			
		||||
            {"id": 2, "name": "Cotis 2 semestres", "quantity": 1, "unit_price": 28},
 | 
			
		||||
            {"id": 4, "name": "Barbar", "quantity": 3, "unit_price": 1.7}
 | 
			
		||||
        ]"""
 | 
			
		||||
        response = self.client.get(reverse("eboutic:command"))
 | 
			
		||||
        assert response.status_code == 200
 | 
			
		||||
        self.assertInHTML(
 | 
			
		||||
            "<tr><td>Cotis 2 semestres</td><td>1</td><td>28.00 €</td></tr>",
 | 
			
		||||
            response.text,
 | 
			
		||||
        )
 | 
			
		||||
        self.assertInHTML(
 | 
			
		||||
            "<tr><td>Barbar</td><td>3</td><td>1.70 €</td></tr>",
 | 
			
		||||
            response.text,
 | 
			
		||||
        )
 | 
			
		||||
        assert "basket_id" in self.client.session
 | 
			
		||||
        basket = Basket.objects.get(id=self.client.session["basket_id"])
 | 
			
		||||
        assert basket.items.count() == 2
 | 
			
		||||
        barbar = basket.items.filter(product_name="Barbar").first()
 | 
			
		||||
        assert barbar is not None
 | 
			
		||||
        assert barbar.quantity == 3
 | 
			
		||||
        cotis = basket.items.filter(product_name="Cotis 2 semestres").first()
 | 
			
		||||
        assert cotis is not None
 | 
			
		||||
        assert cotis.quantity == 1
 | 
			
		||||
        assert basket.total == 3 * 1.7 + 28
 | 
			
		||||
 | 
			
		||||
    def test_submit_empty_basket(self):
 | 
			
		||||
        self.client.force_login(self.subscriber)
 | 
			
		||||
        self.client.cookies["basket_items"] = "[]"
 | 
			
		||||
        response = self.client.get(reverse("eboutic:command"))
 | 
			
		||||
        self.assertRedirects(response, "/eboutic/")
 | 
			
		||||
 | 
			
		||||
    def test_submit_invalid_basket(self):
 | 
			
		||||
        self.client.force_login(self.subscriber)
 | 
			
		||||
        max_id = Product.objects.aggregate(res=Max("id"))["res"]
 | 
			
		||||
        self.client.cookies["basket_items"] = f"""[
 | 
			
		||||
            {{"id": {max_id + 1}, "name": "", "quantity": 1, "unit_price": 28}}
 | 
			
		||||
        ]"""
 | 
			
		||||
        response = self.client.get(reverse("eboutic:command"))
 | 
			
		||||
        cookie = self.client.cookies["basket_items"].OutputString()
 | 
			
		||||
        assert 'basket_items="[]"' in cookie
 | 
			
		||||
        assert "Path=/eboutic" in cookie
 | 
			
		||||
        self.assertRedirects(response, "/eboutic/")
 | 
			
		||||
 | 
			
		||||
    def test_submit_basket_illegal_quantity(self):
 | 
			
		||||
        self.client.force_login(self.subscriber)
 | 
			
		||||
        self.client.cookies["basket_items"] = """[
 | 
			
		||||
            {"id": 4, "name": "Barbar", "quantity": -1, "unit_price": 1.7}
 | 
			
		||||
        ]"""
 | 
			
		||||
        response = self.client.get(reverse("eboutic:command"))
 | 
			
		||||
        self.assertRedirects(response, "/eboutic/")
 | 
			
		||||
 | 
			
		||||
    def test_buy_subscribe_product_with_credit_card(self):
 | 
			
		||||
        self.client.force_login(self.old_subscriber)
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
            reverse("core:user_profile", kwargs={"user_id": self.old_subscriber.id})
 | 
			
		||||
        )
 | 
			
		||||
        assert "Non cotisant" in str(response.content)
 | 
			
		||||
        self.client.cookies["basket_items"] = """[
 | 
			
		||||
            {"id": 2, "name": "Cotis 2 semestres", "quantity": 1, "unit_price": 28}
 | 
			
		||||
        ]"""
 | 
			
		||||
        response = self.client.get(reverse("eboutic:command"))
 | 
			
		||||
        self.assertInHTML(
 | 
			
		||||
            "<tr><td>Cotis 2 semestres</td><td>1</td><td>28.00 €</td></tr>",
 | 
			
		||||
            response.text,
 | 
			
		||||
        )
 | 
			
		||||
        basket = Basket.objects.get(id=self.client.session["basket_id"])
 | 
			
		||||
        assert basket.items.count() == 1
 | 
			
		||||
        response = self.client.get(self.generate_bank_valid_answer())
 | 
			
		||||
        assert response.status_code == 200
 | 
			
		||||
        assert response.content.decode("utf-8") == "Payment successful"
 | 
			
		||||
 | 
			
		||||
        subscriber = User.objects.get(id=self.old_subscriber.id)
 | 
			
		||||
        assert subscriber.subscriptions.count() == 2
 | 
			
		||||
        sub = subscriber.subscriptions.order_by("-subscription_end").first()
 | 
			
		||||
        assert sub.is_valid_now()
 | 
			
		||||
        assert sub.member == subscriber
 | 
			
		||||
        assert sub.subscription_type == "deux-semestres"
 | 
			
		||||
        assert sub.location == "EBOUTIC"
 | 
			
		||||
 | 
			
		||||
    def test_buy_refill_product_with_credit_card(self):
 | 
			
		||||
        self.client.force_login(self.subscriber)
 | 
			
		||||
        # basket contains 1 refill item worth 15€
 | 
			
		||||
        self.client.cookies["basket_items"] = json.dumps(
 | 
			
		||||
            [{"id": 3, "name": "Rechargement 15 €", "quantity": 1, "unit_price": 15}]
 | 
			
		||||
        )
 | 
			
		||||
        initial_balance = self.subscriber.customer.amount
 | 
			
		||||
        self.client.get(reverse("eboutic:command"))
 | 
			
		||||
 | 
			
		||||
        url = self.generate_bank_valid_answer()
 | 
			
		||||
        response = self.client.get(url)
 | 
			
		||||
        assert response.status_code == 200
 | 
			
		||||
        assert response.text == "Payment successful"
 | 
			
		||||
        new_balance = Customer.objects.get(user=self.subscriber).amount
 | 
			
		||||
        assert new_balance == initial_balance + 15
 | 
			
		||||
 | 
			
		||||
    def test_alter_basket_after_submission(self):
 | 
			
		||||
        self.client.force_login(self.subscriber)
 | 
			
		||||
        self.client.cookies["basket_items"] = json.dumps(
 | 
			
		||||
            [{"id": 4, "name": "Barbar", "quantity": 1, "unit_price": 1.7}]
 | 
			
		||||
        )
 | 
			
		||||
        self.client.get(reverse("eboutic:command"))
 | 
			
		||||
        et_answer_url = self.generate_bank_valid_answer()
 | 
			
		||||
        self.client.cookies["basket_items"] = json.dumps(
 | 
			
		||||
            [  # alter basket
 | 
			
		||||
                {"id": 4, "name": "Barbar", "quantity": 3, "unit_price": 1.7}
 | 
			
		||||
            ]
 | 
			
		||||
        )
 | 
			
		||||
        self.client.get(reverse("eboutic:command"))
 | 
			
		||||
        response = self.client.get(et_answer_url)
 | 
			
		||||
        assert response.status_code == 500
 | 
			
		||||
        msg = (
 | 
			
		||||
            "Basket processing failed with error: "
 | 
			
		||||
            "SuspiciousOperation('Basket total and amount do not match'"
 | 
			
		||||
        )
 | 
			
		||||
        assert msg in response.content.decode("utf-8")
 | 
			
		||||
 | 
			
		||||
    def test_buy_simple_product_with_credit_card(self):
 | 
			
		||||
        self.client.force_login(self.subscriber)
 | 
			
		||||
        self.client.cookies["basket_items"] = json.dumps(
 | 
			
		||||
            [{"id": 4, "name": "Barbar", "quantity": 1, "unit_price": 1.7}]
 | 
			
		||||
        )
 | 
			
		||||
        self.client.get(reverse("eboutic:command"))
 | 
			
		||||
        et_answer_url = self.generate_bank_valid_answer()
 | 
			
		||||
        response = self.client.get(et_answer_url)
 | 
			
		||||
        assert response.status_code == 200
 | 
			
		||||
        assert response.content.decode("utf-8") == "Payment successful"
 | 
			
		||||
 | 
			
		||||
        selling = (
 | 
			
		||||
            Selling.objects.filter(customer=self.subscriber.customer)
 | 
			
		||||
            .order_by("-date")
 | 
			
		||||
            .first()
 | 
			
		||||
        )
 | 
			
		||||
        assert selling.payment_method == "CARD"
 | 
			
		||||
        assert selling.quantity == 1
 | 
			
		||||
        assert selling.unit_price == self.barbar.selling_price
 | 
			
		||||
        assert selling.counter.type == "EBOUTIC"
 | 
			
		||||
        assert selling.product == self.barbar
 | 
			
		||||
@@ -27,11 +27,11 @@ from django.urls import path, register_converter
 | 
			
		||||
from eboutic.converters import PaymentResultConverter
 | 
			
		||||
from eboutic.views import (
 | 
			
		||||
    BillingInfoFormFragment,
 | 
			
		||||
    EbouticCommand,
 | 
			
		||||
    EbouticCheckout,
 | 
			
		||||
    EbouticMainView,
 | 
			
		||||
    EbouticPayWithSith,
 | 
			
		||||
    EtransactionAutoAnswer,
 | 
			
		||||
    EurokPartnerFragment,
 | 
			
		||||
    eboutic_main,
 | 
			
		||||
    pay_with_sith,
 | 
			
		||||
    payment_result,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@@ -39,10 +39,12 @@ register_converter(PaymentResultConverter, "res")
 | 
			
		||||
 | 
			
		||||
urlpatterns = [
 | 
			
		||||
    # Subscription views
 | 
			
		||||
    path("", eboutic_main, name="main"),
 | 
			
		||||
    path("command/", EbouticCommand.as_view(), name="command"),
 | 
			
		||||
    path("", EbouticMainView.as_view(), name="main"),
 | 
			
		||||
    path("checkout/<int:basket_id>", EbouticCheckout.as_view(), name="checkout"),
 | 
			
		||||
    path("billing-infos/", BillingInfoFormFragment.as_view(), name="billing_infos"),
 | 
			
		||||
    path("pay/sith/", pay_with_sith, name="pay_with_sith"),
 | 
			
		||||
    path(
 | 
			
		||||
        "pay/sith/<int:basket_id>", EbouticPayWithSith.as_view(), name="pay_with_sith"
 | 
			
		||||
    ),
 | 
			
		||||
    path("pay/<res:result>/", payment_result, name="payment_result"),
 | 
			
		||||
    path("eurok/", EurokPartnerFragment.as_view(), name="eurok"),
 | 
			
		||||
    path(
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										210
									
								
								eboutic/views.py
									
									
									
									
									
								
							
							
						
						
									
										210
									
								
								eboutic/views.py
									
									
									
									
									
								
							@@ -18,7 +18,6 @@ from __future__ import annotations
 | 
			
		||||
import base64
 | 
			
		||||
import contextlib
 | 
			
		||||
import json
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
from typing import TYPE_CHECKING
 | 
			
		||||
 | 
			
		||||
import sentry_sdk
 | 
			
		||||
@@ -33,23 +32,23 @@ from django.contrib.auth.mixins import (
 | 
			
		||||
    LoginRequiredMixin,
 | 
			
		||||
)
 | 
			
		||||
from django.contrib.messages.views import SuccessMessageMixin
 | 
			
		||||
from django.core.exceptions import SuspiciousOperation
 | 
			
		||||
from django.core.exceptions import SuspiciousOperation, ValidationError
 | 
			
		||||
from django.db import DatabaseError, transaction
 | 
			
		||||
from django.db.models.fields import forms
 | 
			
		||||
from django.db.utils import cached_property
 | 
			
		||||
from django.http import HttpRequest, HttpResponse
 | 
			
		||||
from django.http import HttpResponse
 | 
			
		||||
from django.shortcuts import redirect, render
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from django.utils.decorators import method_decorator
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from django.views.decorators.http import require_GET, require_POST
 | 
			
		||||
from django.views.generic import TemplateView, UpdateView, View
 | 
			
		||||
from django.views.decorators.http import require_GET
 | 
			
		||||
from django.views.generic import DetailView, FormView, TemplateView, UpdateView, View
 | 
			
		||||
from django.views.generic.edit import SingleObjectMixin
 | 
			
		||||
from django_countries.fields import Country
 | 
			
		||||
 | 
			
		||||
from core.auth.mixins import IsSubscriberMixin
 | 
			
		||||
from core.auth.mixins import CanViewMixin, IsSubscriberMixin
 | 
			
		||||
from core.views.mixins import FragmentMixin, UseFragmentsMixin
 | 
			
		||||
from counter.forms import BillingInfoForm
 | 
			
		||||
from counter.models import BillingInfo, Counter, Customer, Product
 | 
			
		||||
from eboutic.forms import BasketForm
 | 
			
		||||
from counter.forms import BaseBasketForm, BillingInfoForm, ProductForm
 | 
			
		||||
from counter.models import BillingInfo, Customer, Product, Selling, get_eboutic
 | 
			
		||||
from eboutic.models import (
 | 
			
		||||
    Basket,
 | 
			
		||||
    BasketItem,
 | 
			
		||||
@@ -58,39 +57,82 @@ from eboutic.models import (
 | 
			
		||||
    InvoiceItem,
 | 
			
		||||
    get_eboutic_products,
 | 
			
		||||
)
 | 
			
		||||
from eboutic.schemas import PurchaseItemList, PurchaseItemSchema
 | 
			
		||||
 | 
			
		||||
if TYPE_CHECKING:
 | 
			
		||||
    from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
 | 
			
		||||
    from django.utils.html import SafeString
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@login_required
 | 
			
		||||
@require_GET
 | 
			
		||||
def eboutic_main(request: HttpRequest) -> HttpResponse:
 | 
			
		||||
    """Main view of the eboutic application.
 | 
			
		||||
class BaseEbouticBasketForm(BaseBasketForm):
 | 
			
		||||
    def _check_enough_money(self, *args, **kwargs):
 | 
			
		||||
        # Disable money check
 | 
			
		||||
        ...
 | 
			
		||||
 | 
			
		||||
    Return an Http response whose content is of type text/html.
 | 
			
		||||
    The latter represents the page from which a user can see
 | 
			
		||||
    the catalogue of products that he can buy and fill
 | 
			
		||||
    his shopping cart.
 | 
			
		||||
 | 
			
		||||
EbouticBasketForm = forms.formset_factory(
 | 
			
		||||
    ProductForm, formset=BaseEbouticBasketForm, absolute_max=None, min_num=1
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EbouticMainView(LoginRequiredMixin, FormView):
 | 
			
		||||
    """Main view of the eboutic application.
 | 
			
		||||
 | 
			
		||||
    The purchasable products are those of the eboutic which
 | 
			
		||||
    belong to a category of products of a product category
 | 
			
		||||
    (orphan products are inaccessible).
 | 
			
		||||
 | 
			
		||||
    If the session contains a key-value pair that associates "errors"
 | 
			
		||||
    with a list of strings, this pair is removed from the session
 | 
			
		||||
    and its value displayed to the user when the page is rendered.
 | 
			
		||||
    """
 | 
			
		||||
    errors = request.session.pop("errors", None)
 | 
			
		||||
    products = get_eboutic_products(request.user)
 | 
			
		||||
    context = {
 | 
			
		||||
        "errors": errors,
 | 
			
		||||
        "products": products,
 | 
			
		||||
        "customer_amount": request.user.account_balance,
 | 
			
		||||
    }
 | 
			
		||||
    return render(request, "eboutic/eboutic_main.jinja", context)
 | 
			
		||||
 | 
			
		||||
    template_name = "eboutic/eboutic_main.jinja"
 | 
			
		||||
    form_class = EbouticBasketForm
 | 
			
		||||
 | 
			
		||||
    def get_form_kwargs(self):
 | 
			
		||||
        kwargs = super().get_form_kwargs()
 | 
			
		||||
        kwargs["form_kwargs"] = {
 | 
			
		||||
            "customer": self.customer,
 | 
			
		||||
            "counter": get_eboutic(),
 | 
			
		||||
            "allowed_products": {product.id: product for product in self.products},
 | 
			
		||||
        }
 | 
			
		||||
        return kwargs
 | 
			
		||||
 | 
			
		||||
    def form_valid(self, formset):
 | 
			
		||||
        if len(formset) == 0:
 | 
			
		||||
            formset.errors.append(_("Your basket is empty"))
 | 
			
		||||
            return self.form_invalid(formset)
 | 
			
		||||
 | 
			
		||||
        with transaction.atomic():
 | 
			
		||||
            self.basket = Basket.objects.create(user=self.request.user)
 | 
			
		||||
            for form in formset:
 | 
			
		||||
                BasketItem.from_product(
 | 
			
		||||
                    form.product, form.cleaned_data["quantity"], self.basket
 | 
			
		||||
                ).save()
 | 
			
		||||
            self.basket.save()
 | 
			
		||||
        return super().form_valid(formset)
 | 
			
		||||
 | 
			
		||||
    def get_success_url(self):
 | 
			
		||||
        return reverse("eboutic:checkout", kwargs={"basket_id": self.basket.id})
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def products(self) -> list[Product]:
 | 
			
		||||
        return get_eboutic_products(self.request.user)
 | 
			
		||||
 | 
			
		||||
    @cached_property
 | 
			
		||||
    def customer(self) -> Customer:
 | 
			
		||||
        return Customer.get_or_create(self.request.user)[0]
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        context = super().get_context_data(**kwargs)
 | 
			
		||||
        context["products"] = self.products
 | 
			
		||||
        context["customer_amount"] = self.request.user.account_balance
 | 
			
		||||
        last_purchase: Selling | None = (
 | 
			
		||||
            self.customer.buyings.filter(counter__type="EBOUTIC")
 | 
			
		||||
            .order_by("-date")
 | 
			
		||||
            .first()
 | 
			
		||||
        )
 | 
			
		||||
        context["last_purchase_time"] = (
 | 
			
		||||
            int(last_purchase.date.timestamp() * 1000) if last_purchase else "null"
 | 
			
		||||
        )
 | 
			
		||||
        return context
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@require_GET
 | 
			
		||||
@@ -166,48 +208,15 @@ class BillingInfoFormFragment(
 | 
			
		||||
        return self.request.path
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EbouticCommand(LoginRequiredMixin, UseFragmentsMixin, TemplateView):
 | 
			
		||||
    template_name = "eboutic/eboutic_makecommand.jinja"
 | 
			
		||||
    basket: Basket
 | 
			
		||||
class EbouticCheckout(CanViewMixin, UseFragmentsMixin, DetailView):
 | 
			
		||||
    model = Basket
 | 
			
		||||
    pk_url_kwarg = "basket_id"
 | 
			
		||||
    context_object_name = "basket"
 | 
			
		||||
    template_name = "eboutic/eboutic_checkout.jinja"
 | 
			
		||||
    fragments = {
 | 
			
		||||
        "billing_infos_form": BillingInfoFormFragment,
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @method_decorator(login_required)
 | 
			
		||||
    def post(self, request, *args, **kwargs):
 | 
			
		||||
        return redirect("eboutic:main")
 | 
			
		||||
 | 
			
		||||
    def get(self, request: HttpRequest, *args, **kwargs):
 | 
			
		||||
        form = BasketForm(request)
 | 
			
		||||
        if not form.is_valid():
 | 
			
		||||
            request.session["errors"] = form.errors
 | 
			
		||||
            request.session.modified = True
 | 
			
		||||
            res = redirect("eboutic:main")
 | 
			
		||||
            res.set_cookie(
 | 
			
		||||
                "basket_items",
 | 
			
		||||
                PurchaseItemList.dump_json(form.cleaned_data, by_alias=True).decode(),
 | 
			
		||||
                path="/eboutic",
 | 
			
		||||
            )
 | 
			
		||||
            return res
 | 
			
		||||
        basket = Basket.from_session(request.session)
 | 
			
		||||
        if basket is not None:
 | 
			
		||||
            basket.items.all().delete()
 | 
			
		||||
        else:
 | 
			
		||||
            basket = Basket.objects.create(user=request.user)
 | 
			
		||||
            request.session["basket_id"] = basket.id
 | 
			
		||||
            request.session.modified = True
 | 
			
		||||
 | 
			
		||||
        items: list[PurchaseItemSchema] = form.cleaned_data
 | 
			
		||||
        pks = {item.product_id for item in items}
 | 
			
		||||
        products = {p.pk: p for p in Product.objects.filter(pk__in=pks)}
 | 
			
		||||
        db_items = []
 | 
			
		||||
        for pk in pks:
 | 
			
		||||
            quantity = sum(i.quantity for i in items if i.product_id == pk)
 | 
			
		||||
            db_items.append(BasketItem.from_product(products[pk], quantity, basket))
 | 
			
		||||
        BasketItem.objects.bulk_create(db_items)
 | 
			
		||||
        self.basket = basket
 | 
			
		||||
        return super().get(request)
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        kwargs = super().get_context_data(**kwargs)
 | 
			
		||||
        if hasattr(self.request.user, "customer"):
 | 
			
		||||
@@ -215,51 +224,44 @@ class EbouticCommand(LoginRequiredMixin, UseFragmentsMixin, TemplateView):
 | 
			
		||||
            kwargs["customer_amount"] = customer.amount
 | 
			
		||||
        else:
 | 
			
		||||
            kwargs["customer_amount"] = None
 | 
			
		||||
        kwargs["basket"] = self.basket
 | 
			
		||||
        kwargs["billing_infos"] = {}
 | 
			
		||||
 | 
			
		||||
        with contextlib.suppress(BillingInfo.DoesNotExist):
 | 
			
		||||
            kwargs["billing_infos"] = json.dumps(
 | 
			
		||||
                dict(self.basket.get_e_transaction_data())
 | 
			
		||||
                dict(self.object.get_e_transaction_data())
 | 
			
		||||
            )
 | 
			
		||||
        return kwargs
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@login_required
 | 
			
		||||
@require_POST
 | 
			
		||||
def pay_with_sith(request):
 | 
			
		||||
    basket = Basket.from_session(request.session)
 | 
			
		||||
    refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
 | 
			
		||||
    if basket is None or basket.items.filter(type_id=refilling).exists():
 | 
			
		||||
        return redirect("eboutic:main")
 | 
			
		||||
    c = Customer.objects.filter(user__id=basket.user_id).first()
 | 
			
		||||
    if c is None:
 | 
			
		||||
        return redirect("eboutic:main")
 | 
			
		||||
    if c.amount < basket.total:
 | 
			
		||||
        res = redirect("eboutic:payment_result", "failure")
 | 
			
		||||
        res.delete_cookie("basket_items", "/eboutic")
 | 
			
		||||
        return res
 | 
			
		||||
    eboutic = Counter.objects.get(type="EBOUTIC")
 | 
			
		||||
    sales = basket.generate_sales(eboutic, c.user, "SITH_ACCOUNT")
 | 
			
		||||
    try:
 | 
			
		||||
        with transaction.atomic():
 | 
			
		||||
            # Selling.save has some important business logic in it.
 | 
			
		||||
            # Do not bulk_create this
 | 
			
		||||
            for sale in sales:
 | 
			
		||||
                sale.save()
 | 
			
		||||
            basket.delete()
 | 
			
		||||
        request.session.pop("basket_id", None)
 | 
			
		||||
        res = redirect("eboutic:payment_result", "success")
 | 
			
		||||
    except DatabaseError as e:
 | 
			
		||||
        with sentry_sdk.push_scope() as scope:
 | 
			
		||||
            scope.user = {"username": request.user.username}
 | 
			
		||||
            scope.set_extra("someVariable", e.__repr__())
 | 
			
		||||
            sentry_sdk.capture_message(
 | 
			
		||||
                f"Erreur le {datetime.now()} dans eboutic.pay_with_sith"
 | 
			
		||||
class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
 | 
			
		||||
    model = Basket
 | 
			
		||||
    pk_url_kwarg = "basket_id"
 | 
			
		||||
 | 
			
		||||
    def post(self, request, *args, **kwargs):
 | 
			
		||||
        basket = self.get_object()
 | 
			
		||||
        refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
 | 
			
		||||
        if basket.items.filter(type_id=refilling).exists():
 | 
			
		||||
            messages.error(
 | 
			
		||||
                self.request,
 | 
			
		||||
                _("You can't buy a refilling with sith money"),
 | 
			
		||||
            )
 | 
			
		||||
        res = redirect("eboutic:payment_result", "failure")
 | 
			
		||||
    res.delete_cookie("basket_items", "/eboutic")
 | 
			
		||||
    return res
 | 
			
		||||
            return redirect("eboutic:payment_result", "failure")
 | 
			
		||||
 | 
			
		||||
        eboutic = get_eboutic()
 | 
			
		||||
        sales = basket.generate_sales(eboutic, basket.user, "SITH_ACCOUNT")
 | 
			
		||||
        try:
 | 
			
		||||
            with transaction.atomic():
 | 
			
		||||
                # Selling.save has some important business logic in it.
 | 
			
		||||
                # Do not bulk_create this
 | 
			
		||||
                for sale in sales:
 | 
			
		||||
                    sale.save()
 | 
			
		||||
                basket.delete()
 | 
			
		||||
            return redirect("eboutic:payment_result", "success")
 | 
			
		||||
        except DatabaseError as e:
 | 
			
		||||
            sentry_sdk.capture_exception(e)
 | 
			
		||||
        except ValidationError as e:
 | 
			
		||||
            messages.error(self.request, e.message)
 | 
			
		||||
        return redirect("eboutic:payment_result", "failure")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EtransactionAutoAnswer(View):
 | 
			
		||||
 
 | 
			
		||||
@@ -1,39 +0,0 @@
 | 
			
		||||
#
 | 
			
		||||
# Copyright 2023 © AE UTBM
 | 
			
		||||
# ae@utbm.fr / ae.info@utbm.fr
 | 
			
		||||
#
 | 
			
		||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
 | 
			
		||||
# https://ae.utbm.fr.
 | 
			
		||||
#
 | 
			
		||||
# You can find the source code of the website at https://github.com/ae-utbm/sith
 | 
			
		||||
#
 | 
			
		||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
 | 
			
		||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
 | 
			
		||||
# OR WITHIN THE LOCAL FILE "LICENSE"
 | 
			
		||||
#
 | 
			
		||||
#
 | 
			
		||||
from django.contrib import admin
 | 
			
		||||
 | 
			
		||||
from launderette.models import Launderette, Machine, Slot, Token
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(Launderette)
 | 
			
		||||
class LaunderetteAdmin(admin.ModelAdmin):
 | 
			
		||||
    list_display = ("name", "counter")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(Machine)
 | 
			
		||||
class MachineAdmin(admin.ModelAdmin):
 | 
			
		||||
    list_display = ("name", "launderette", "type", "is_working")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(Token)
 | 
			
		||||
class TokenAdmin(admin.ModelAdmin):
 | 
			
		||||
    list_display = ("name", "launderette", "type", "user")
 | 
			
		||||
    autocomplete_fields = ("user",)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@admin.register(Slot)
 | 
			
		||||
class SlotAdmin(admin.ModelAdmin):
 | 
			
		||||
    list_display = ("machine", "user", "start_date")
 | 
			
		||||
    autocomplete_fields = ("user",)
 | 
			
		||||
@@ -0,0 +1,14 @@
 | 
			
		||||
# Generated by Django 5.2 on 2025-04-15 19:37
 | 
			
		||||
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [("launderette", "0001_initial")]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.DeleteModel(name="Launderette"),
 | 
			
		||||
        migrations.DeleteModel(name="Machine"),
 | 
			
		||||
        migrations.DeleteModel(name="Slot"),
 | 
			
		||||
        migrations.DeleteModel(name="Token"),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -1,193 +0,0 @@
 | 
			
		||||
#
 | 
			
		||||
# Copyright 2023 © AE UTBM
 | 
			
		||||
# ae@utbm.fr / ae.info@utbm.fr
 | 
			
		||||
#
 | 
			
		||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
 | 
			
		||||
# https://ae.utbm.fr.
 | 
			
		||||
#
 | 
			
		||||
# You can find the source code of the website at https://github.com/ae-utbm/sith
 | 
			
		||||
#
 | 
			
		||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
 | 
			
		||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
 | 
			
		||||
# OR WITHIN THE LOCAL FILE "LICENSE"
 | 
			
		||||
#
 | 
			
		||||
#
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db import DataError, models
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
 | 
			
		||||
from club.models import Club
 | 
			
		||||
from core.models import User
 | 
			
		||||
from counter.models import Counter
 | 
			
		||||
 | 
			
		||||
# Create your models here.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Launderette(models.Model):
 | 
			
		||||
    name = models.CharField(_("name"), max_length=30)
 | 
			
		||||
    counter = models.OneToOneField(
 | 
			
		||||
        Counter,
 | 
			
		||||
        verbose_name=_("counter"),
 | 
			
		||||
        related_name="launderette",
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("Launderette")
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.name
 | 
			
		||||
 | 
			
		||||
    def get_absolute_url(self):
 | 
			
		||||
        return reverse("launderette:launderette_list")
 | 
			
		||||
 | 
			
		||||
    def is_owned_by(self, user):
 | 
			
		||||
        """Method to see if that object can be edited by the given user."""
 | 
			
		||||
        if user.is_anonymous:
 | 
			
		||||
            return False
 | 
			
		||||
        launderette_club = Club.objects.get(id=settings.SITH_LAUNDERETTE_CLUB_ID)
 | 
			
		||||
        m = launderette_club.get_membership_for(user)
 | 
			
		||||
        return bool(m and m.role >= 9)
 | 
			
		||||
 | 
			
		||||
    def can_be_edited_by(self, user):
 | 
			
		||||
        launderette_club = Club.objects.get(id=settings.SITH_LAUNDERETTE_CLUB_ID)
 | 
			
		||||
        m = launderette_club.get_membership_for(user)
 | 
			
		||||
        return bool(m and m.role >= 2)
 | 
			
		||||
 | 
			
		||||
    def can_be_viewed_by(self, user):
 | 
			
		||||
        return user.is_subscribed
 | 
			
		||||
 | 
			
		||||
    def get_machine_list(self):
 | 
			
		||||
        return Machine.objects.filter(launderette_id=self.id)
 | 
			
		||||
 | 
			
		||||
    def machine_list(self):
 | 
			
		||||
        return [m.id for m in self.get_machine_list()]
 | 
			
		||||
 | 
			
		||||
    def get_token_list(self):
 | 
			
		||||
        return Token.objects.filter(launderette_id=self.id)
 | 
			
		||||
 | 
			
		||||
    def token_list(self):
 | 
			
		||||
        return [t.id for t in self.get_token_list()]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Machine(models.Model):
 | 
			
		||||
    name = models.CharField(_("name"), max_length=30)
 | 
			
		||||
    launderette = models.ForeignKey(
 | 
			
		||||
        Launderette,
 | 
			
		||||
        related_name="machines",
 | 
			
		||||
        verbose_name=_("launderette"),
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
    )
 | 
			
		||||
    type = models.CharField(
 | 
			
		||||
        _("type"), max_length=10, choices=settings.SITH_LAUNDERETTE_MACHINE_TYPES
 | 
			
		||||
    )
 | 
			
		||||
    is_working = models.BooleanField(_("is working"), default=True)
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("Machine")
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return "%s %s" % (self._meta.verbose_name, self.name)
 | 
			
		||||
 | 
			
		||||
    def get_absolute_url(self):
 | 
			
		||||
        return reverse(
 | 
			
		||||
            "launderette:launderette_admin",
 | 
			
		||||
            kwargs={"launderette_id": self.launderette.id},
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def is_owned_by(self, user):
 | 
			
		||||
        """Method to see if that object can be edited by the given user."""
 | 
			
		||||
        if user.is_anonymous:
 | 
			
		||||
            return False
 | 
			
		||||
        launderette_club = Club.objects.get(id=settings.SITH_LAUNDERETTE_CLUB_ID)
 | 
			
		||||
        m = launderette_club.get_membership_for(user)
 | 
			
		||||
        return bool(m and m.role >= 9)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Token(models.Model):
 | 
			
		||||
    name = models.CharField(_("name"), max_length=5)
 | 
			
		||||
    launderette = models.ForeignKey(
 | 
			
		||||
        Launderette,
 | 
			
		||||
        related_name="tokens",
 | 
			
		||||
        verbose_name=_("launderette"),
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
    )
 | 
			
		||||
    type = models.CharField(
 | 
			
		||||
        _("type"), max_length=10, choices=settings.SITH_LAUNDERETTE_MACHINE_TYPES
 | 
			
		||||
    )
 | 
			
		||||
    borrow_date = models.DateTimeField(_("borrow date"), null=True, blank=True)
 | 
			
		||||
    user = models.ForeignKey(
 | 
			
		||||
        User,
 | 
			
		||||
        related_name="tokens",
 | 
			
		||||
        verbose_name=_("user"),
 | 
			
		||||
        null=True,
 | 
			
		||||
        blank=True,
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("Token")
 | 
			
		||||
        unique_together = ("name", "launderette", "type")
 | 
			
		||||
        ordering = ["type", "name"]
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return (
 | 
			
		||||
            f"{self.__class__._meta.verbose_name} {self.get_type_display()} "
 | 
			
		||||
            f"#{self.name} ({self.launderette.name})"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def save(self, *args, **kwargs):
 | 
			
		||||
        if self.name == "":
 | 
			
		||||
            raise DataError(_("Token name can not be blank"))
 | 
			
		||||
        else:
 | 
			
		||||
            super().save(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def is_owned_by(self, user):
 | 
			
		||||
        """Method to see if that object can be edited by the given user."""
 | 
			
		||||
        if user.is_anonymous:
 | 
			
		||||
            return False
 | 
			
		||||
        launderette_club = Club.objects.get(id=settings.SITH_LAUNDERETTE_CLUB_ID)
 | 
			
		||||
        m = launderette_club.get_membership_for(user)
 | 
			
		||||
        return bool(m and m.role >= 9)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Slot(models.Model):
 | 
			
		||||
    start_date = models.DateTimeField(_("start date"))
 | 
			
		||||
    type = models.CharField(
 | 
			
		||||
        _("type"), max_length=10, choices=settings.SITH_LAUNDERETTE_MACHINE_TYPES
 | 
			
		||||
    )
 | 
			
		||||
    machine = models.ForeignKey(
 | 
			
		||||
        Machine,
 | 
			
		||||
        related_name="slots",
 | 
			
		||||
        verbose_name=_("machine"),
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
    )
 | 
			
		||||
    token = models.ForeignKey(
 | 
			
		||||
        Token,
 | 
			
		||||
        related_name="slots",
 | 
			
		||||
        verbose_name=_("token"),
 | 
			
		||||
        blank=True,
 | 
			
		||||
        null=True,
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
    )
 | 
			
		||||
    user = models.ForeignKey(
 | 
			
		||||
        User, related_name="slots", verbose_name=_("user"), on_delete=models.CASCADE
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("Slot")
 | 
			
		||||
        ordering = ["start_date"]
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return "User: %s - Date: %s - Type: %s - Machine: %s - Token: %s" % (
 | 
			
		||||
            self.user,
 | 
			
		||||
            self.start_date,
 | 
			
		||||
            self.get_type_display(),
 | 
			
		||||
            self.machine.name,
 | 
			
		||||
            self.token,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def is_owned_by(self, user):
 | 
			
		||||
        return user == self.user
 | 
			
		||||
@@ -1,69 +0,0 @@
 | 
			
		||||
{% extends "core/base.jinja" %}
 | 
			
		||||
 | 
			
		||||
{% block title %}
 | 
			
		||||
  {% trans %}Launderette admin{% endtrans %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
  <h3>{% trans %}Selling{% endtrans %}</h3>
 | 
			
		||||
  <p><a href="{{ url('launderette:main_click', launderette_id=launderette.id) }}">{% trans %}Sell{% endtrans %}</a></p>
 | 
			
		||||
  <hr>
 | 
			
		||||
  <h3>{% trans %}Machines{% endtrans %}</h3>
 | 
			
		||||
  <p><a href="{{ url('launderette:machine_new') }}?launderette={{ launderette.id }}">{% trans %}New machine{% endtrans %}</a></p>
 | 
			
		||||
  <ul>
 | 
			
		||||
    {% for m in launderette.machines.all() %}
 | 
			
		||||
      <li><a href="{{ url('launderette:machine_edit', machine_id=m.id) }}">{{ m }}</a> -
 | 
			
		||||
        <a href="{{ url('launderette:machine_delete', machine_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></li>
 | 
			
		||||
    {% endfor %}
 | 
			
		||||
  </ul>
 | 
			
		||||
  <hr>
 | 
			
		||||
  <h3>{% trans %}Tokens{% endtrans %}</h3>
 | 
			
		||||
  <p>
 | 
			
		||||
    <form method="post" action="" id="token_form">
 | 
			
		||||
      {% csrf_token %}
 | 
			
		||||
      <p>{{ form.action.errors }}<label for="{{ form.action.name }}">{{ form.action.label }}</label>
 | 
			
		||||
        {% for c in form.action %}
 | 
			
		||||
          {{ c }}
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
      </p>
 | 
			
		||||
      <p>{{ form.token_type.errors }}<label for="{{ form.token_type.name }}">{{ form.token_type.label }}</label>
 | 
			
		||||
        {% for c in form.token_type %}
 | 
			
		||||
          {{ c }}
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
      </p>
 | 
			
		||||
      {{ form.tokens }}
 | 
			
		||||
      <p><input type="submit" value="{% trans %}Go{% endtrans %}" /></p>
 | 
			
		||||
    </form>
 | 
			
		||||
  </p>
 | 
			
		||||
  <p>
 | 
			
		||||
    <table>
 | 
			
		||||
      <thead>
 | 
			
		||||
        <tr>
 | 
			
		||||
          <td>{% trans %}Type{% endtrans %}</td>
 | 
			
		||||
          <td>{% trans %}Name{% endtrans %}</td>
 | 
			
		||||
          <td>{% trans %}User{% endtrans %}</td>
 | 
			
		||||
          <td>{% trans %}Since{% endtrans %}</td>
 | 
			
		||||
        </tr>
 | 
			
		||||
      </thead>
 | 
			
		||||
      <tbody>
 | 
			
		||||
        {% for t in launderette.tokens.all() %}
 | 
			
		||||
          <tr>
 | 
			
		||||
            <td>{{ t.get_type_display() }}</td>
 | 
			
		||||
            <td>{{ t.name }}</td>
 | 
			
		||||
            {% if t.user %}
 | 
			
		||||
              <td>{{ t.user.get_display_name() }}</td>
 | 
			
		||||
              <td>{{ t.borrow_date|date(DATETIME_FORMAT) }} - {{ t.borrow_date|time(DATETIME_FORMAT) }}</td>
 | 
			
		||||
            {% else %}
 | 
			
		||||
              <td></td>
 | 
			
		||||
              <td></td>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
          </tr>
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
      </tbody>
 | 
			
		||||
    </table>
 | 
			
		||||
  </p>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -1,58 +0,0 @@
 | 
			
		||||
{% extends "core/base.jinja" %}
 | 
			
		||||
{% from "core/macros.jinja" import show_slots, show_tokens %}
 | 
			
		||||
 | 
			
		||||
{% block title %}
 | 
			
		||||
  {% trans %}Launderette{% endtrans %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% macro choose(date) %}
 | 
			
		||||
  <form method="post" action="{{ url('launderette:book_slot', launderette_id=launderette.id) }}" class="inline" style="display:inline">
 | 
			
		||||
    {% csrf_token %}
 | 
			
		||||
    <input type="hidden" name="slot_type" value="{{ slot_type }}">
 | 
			
		||||
    <button type="submit" name="slot" value="{{ date.isoformat() }}">{% trans %}Choose{% endtrans %}</button>
 | 
			
		||||
  </form>
 | 
			
		||||
{% endmacro %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
  <h3>{{ launderette }}</h3>
 | 
			
		||||
  <p>
 | 
			
		||||
    <form method="post" action="{{ url('launderette:book_slot', launderette_id=launderette.id) }}"
 | 
			
		||||
          class="inline" style="display:inline">
 | 
			
		||||
      {% csrf_token %}
 | 
			
		||||
      <button type="submit" name="slot_type" value="BOTH" {% if slot_type == "BOTH" -%}style="background: #FF0"{% endif %}>{% trans %}Washing and drying{% endtrans %}</button>
 | 
			
		||||
    </form>
 | 
			
		||||
    <form method="post" action="{{ url('launderette:book_slot', launderette_id=launderette.id) }}" class="inline" style="display:inline">
 | 
			
		||||
      {% csrf_token %}
 | 
			
		||||
      <button type="submit" name="slot_type" value="WASHING" {% if slot_type == "WASHING" -%}style="background: #FF0"{% endif %}>{% trans %}Washing{% endtrans %}</button>
 | 
			
		||||
    </form>
 | 
			
		||||
    <form method="post" action="{{ url('launderette:book_slot', launderette_id=launderette.id) }}" class="inline" style="display:inline">
 | 
			
		||||
      {% csrf_token %}
 | 
			
		||||
      <button type="submit" name="slot_type" value="DRYING" {% if slot_type == "DRYING" -%}style="background: #FF0"{% endif %}>{% trans %}Drying{% endtrans %}</button>
 | 
			
		||||
    </form>
 | 
			
		||||
  </p>
 | 
			
		||||
  <table>
 | 
			
		||||
    <thead>
 | 
			
		||||
      <tr>
 | 
			
		||||
        {% for day in planning.keys() %}
 | 
			
		||||
          <th>{{ day|date('l') }}</th>
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
      </tr>
 | 
			
		||||
    </thead>
 | 
			
		||||
    <tbody>
 | 
			
		||||
      {% for i in range(0, 24) %}
 | 
			
		||||
        <tr>
 | 
			
		||||
          {% for hours in planning.values() %}
 | 
			
		||||
            <td>
 | 
			
		||||
              {% if hours[i] %}
 | 
			
		||||
                {{ hours[i]|time(TIME_FORMAT) }} {{ choose(hours[i]) }}
 | 
			
		||||
              {% endif %}
 | 
			
		||||
            </td>
 | 
			
		||||
          {% endfor %}
 | 
			
		||||
        </tr>
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
    </tbody>
 | 
			
		||||
  </table>
 | 
			
		||||
  {{ show_slots(user) }}
 | 
			
		||||
  {{ show_tokens(user) }}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
@@ -1,21 +0,0 @@
 | 
			
		||||
{% extends "core/base.jinja" %}
 | 
			
		||||
 | 
			
		||||
{% block title %}
 | 
			
		||||
  {% trans %}Launderette{% endtrans %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
  {% if request.user.is_subscribed %}
 | 
			
		||||
    <ul>
 | 
			
		||||
      {% for l in launderette_list  %}
 | 
			
		||||
        <li><a href="{{ url('launderette:book_slot', launderette_id=l.id) }}">{{ l }}</a></li>
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
    </ul>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -1,16 +0,0 @@
 | 
			
		||||
{% extends "core/base.jinja" %}
 | 
			
		||||
 | 
			
		||||
{% block title %}
 | 
			
		||||
  {{ counter }}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
  <h3>{% trans counter_name=counter %}{{ counter_name }} counter{% endtrans %}</h3>
 | 
			
		||||
  <div>
 | 
			
		||||
    <form method="post" action="" class="inline" style="display:inline">
 | 
			
		||||
      {% csrf_token %}
 | 
			
		||||
      {{ form.as_p() }}
 | 
			
		||||
      <p><input type="submit" value="{% trans %}Go{% endtrans %}" /></p>
 | 
			
		||||
    </form>
 | 
			
		||||
  </div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
@@ -1,25 +0,0 @@
 | 
			
		||||
{% extends "core/base.jinja" %}
 | 
			
		||||
 | 
			
		||||
{% block title %}
 | 
			
		||||
  {% trans %}Launderette admin list{% endtrans %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
  {% if user.is_root %}
 | 
			
		||||
    <p><a href="{{ url('launderette:launderette_new') }}">{% trans %}New launderette{% endtrans %}</a></p>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
  {% if launderette_list %}
 | 
			
		||||
    <h3>{% trans %}Launderette admin list{% endtrans %}</h3>
 | 
			
		||||
    <ul>
 | 
			
		||||
      {% for l in launderette_list  %}
 | 
			
		||||
        <li><a href="{{ url('launderette:launderette_admin', launderette_id=l.id) }}">{{ l }}</a> -
 | 
			
		||||
          <a href="{{ url('launderette:launderette_edit', launderette_id=l.id) }}">{% trans %}Edit{% endtrans %}</a></li>
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
    </ul>
 | 
			
		||||
  {% else %}
 | 
			
		||||
    {% trans %}There is no launderette in this website.{% endtrans %}
 | 
			
		||||
  {% endif %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -1,21 +0,0 @@
 | 
			
		||||
{% extends "core/base.jinja" %}
 | 
			
		||||
 | 
			
		||||
{% block title %}
 | 
			
		||||
  {% trans %}Launderette{% endtrans %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
  {% if request.user.can_edit(page) %}
 | 
			
		||||
    <p><a href="{{ url('core:page_edit', page_name=page.get_full_name()) }}">{% trans %}Edit presentation page{% endtrans %}</a></p>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
  {% if request.user.is_subscribed %}
 | 
			
		||||
    <p><a href="{{ url('launderette:book_main') }}">{% trans %}Book launderette slot{% endtrans %}</a></p>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
 | 
			
		||||
  {{ page.revisions.last().content|markdown }}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -1,16 +0,0 @@
 | 
			
		||||
#
 | 
			
		||||
# Copyright 2023 © AE UTBM
 | 
			
		||||
# ae@utbm.fr / ae.info@utbm.fr
 | 
			
		||||
#
 | 
			
		||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
 | 
			
		||||
# https://ae.utbm.fr.
 | 
			
		||||
#
 | 
			
		||||
# You can find the source code of the website at https://github.com/ae-utbm/sith
 | 
			
		||||
#
 | 
			
		||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
 | 
			
		||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
 | 
			
		||||
# OR WITHIN THE LOCAL FILE "LICENSE"
 | 
			
		||||
#
 | 
			
		||||
#
 | 
			
		||||
 | 
			
		||||
# Create your tests here.
 | 
			
		||||
@@ -1,73 +0,0 @@
 | 
			
		||||
#
 | 
			
		||||
# Copyright 2023 © AE UTBM
 | 
			
		||||
# ae@utbm.fr / ae.info@utbm.fr
 | 
			
		||||
#
 | 
			
		||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
 | 
			
		||||
# https://ae.utbm.fr.
 | 
			
		||||
#
 | 
			
		||||
# You can find the source code of the website at https://github.com/ae-utbm/sith
 | 
			
		||||
#
 | 
			
		||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
 | 
			
		||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
 | 
			
		||||
# OR WITHIN THE LOCAL FILE "LICENSE"
 | 
			
		||||
#
 | 
			
		||||
#
 | 
			
		||||
 | 
			
		||||
from django.urls import path
 | 
			
		||||
 | 
			
		||||
from launderette.views import (
 | 
			
		||||
    LaunderetteAdminView,
 | 
			
		||||
    LaunderetteBookMainView,
 | 
			
		||||
    LaunderetteBookView,
 | 
			
		||||
    LaunderetteClickView,
 | 
			
		||||
    LaunderetteCreateView,
 | 
			
		||||
    LaunderetteEditView,
 | 
			
		||||
    LaunderetteListView,
 | 
			
		||||
    LaunderetteMainClickView,
 | 
			
		||||
    LaunderetteMainView,
 | 
			
		||||
    MachineCreateView,
 | 
			
		||||
    MachineDeleteView,
 | 
			
		||||
    MachineEditView,
 | 
			
		||||
    SlotDeleteView,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
urlpatterns = [
 | 
			
		||||
    # views
 | 
			
		||||
    path("", LaunderetteMainView.as_view(), name="launderette_main"),
 | 
			
		||||
    path("slot/<int:slot_id>/delete/", SlotDeleteView.as_view(), name="delete_slot"),
 | 
			
		||||
    path("book/", LaunderetteBookMainView.as_view(), name="book_main"),
 | 
			
		||||
    path("book/<int:launderette_id>/", LaunderetteBookView.as_view(), name="book_slot"),
 | 
			
		||||
    path(
 | 
			
		||||
        "<int:launderette_id>/click/",
 | 
			
		||||
        LaunderetteMainClickView.as_view(),
 | 
			
		||||
        name="main_click",
 | 
			
		||||
    ),
 | 
			
		||||
    path(
 | 
			
		||||
        "<int:launderette_id>/click/<int:user_id>/",
 | 
			
		||||
        LaunderetteClickView.as_view(),
 | 
			
		||||
        name="click",
 | 
			
		||||
    ),
 | 
			
		||||
    path("admin/", LaunderetteListView.as_view(), name="launderette_list"),
 | 
			
		||||
    path(
 | 
			
		||||
        "admin/<int:launderette_id>/",
 | 
			
		||||
        LaunderetteAdminView.as_view(),
 | 
			
		||||
        name="launderette_admin",
 | 
			
		||||
    ),
 | 
			
		||||
    path(
 | 
			
		||||
        "admin/<int:launderette_id>/edit/",
 | 
			
		||||
        LaunderetteEditView.as_view(),
 | 
			
		||||
        name="launderette_edit",
 | 
			
		||||
    ),
 | 
			
		||||
    path("admin/new/", LaunderetteCreateView.as_view(), name="launderette_new"),
 | 
			
		||||
    path("admin/machine/new/", MachineCreateView.as_view(), name="machine_new"),
 | 
			
		||||
    path(
 | 
			
		||||
        "admin/machine/<int:machine_id>/edit/",
 | 
			
		||||
        MachineEditView.as_view(),
 | 
			
		||||
        name="machine_edit",
 | 
			
		||||
    ),
 | 
			
		||||
    path(
 | 
			
		||||
        "admin/machine/<int:machine_id>/delete/",
 | 
			
		||||
        MachineDeleteView.as_view(),
 | 
			
		||||
        name="machine_delete",
 | 
			
		||||
    ),
 | 
			
		||||
]
 | 
			
		||||
@@ -1,511 +0,0 @@
 | 
			
		||||
#
 | 
			
		||||
# Copyright 2023 © AE UTBM
 | 
			
		||||
# ae@utbm.fr / ae.info@utbm.fr
 | 
			
		||||
#
 | 
			
		||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
 | 
			
		||||
# https://ae.utbm.fr.
 | 
			
		||||
#
 | 
			
		||||
# You can find the source code of the website at https://github.com/ae-utbm/sith
 | 
			
		||||
#
 | 
			
		||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
 | 
			
		||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
 | 
			
		||||
# OR WITHIN THE LOCAL FILE "LICENSE"
 | 
			
		||||
#
 | 
			
		||||
#
 | 
			
		||||
 | 
			
		||||
from collections import OrderedDict
 | 
			
		||||
from datetime import datetime, timedelta
 | 
			
		||||
from datetime import timezone as tz
 | 
			
		||||
 | 
			
		||||
from django import forms
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.auth.mixins import PermissionRequiredMixin
 | 
			
		||||
from django.db import transaction
 | 
			
		||||
from django.template import defaultfilters
 | 
			
		||||
from django.urls import reverse_lazy
 | 
			
		||||
from django.utils import dateparse, timezone
 | 
			
		||||
from django.utils.translation import gettext as _
 | 
			
		||||
from django.views.generic import DetailView, ListView, TemplateView
 | 
			
		||||
from django.views.generic.edit import BaseFormView, CreateView, DeleteView, UpdateView
 | 
			
		||||
 | 
			
		||||
from club.models import Club
 | 
			
		||||
from core.auth.mixins import CanEditMixin, CanEditPropMixin, CanViewMixin
 | 
			
		||||
from core.models import Page, User
 | 
			
		||||
from counter.forms import GetUserForm
 | 
			
		||||
from counter.models import Counter, Customer, Selling
 | 
			
		||||
from launderette.models import Launderette, Machine, Slot, Token
 | 
			
		||||
 | 
			
		||||
# For users
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class LaunderetteMainView(TemplateView):
 | 
			
		||||
    """Main presentation view."""
 | 
			
		||||
 | 
			
		||||
    template_name = "launderette/launderette_main.jinja"
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        """Add page to the context."""
 | 
			
		||||
        kwargs = super().get_context_data(**kwargs)
 | 
			
		||||
        kwargs["page"] = Page.objects.filter(name="launderette").first()
 | 
			
		||||
        return kwargs
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class LaunderetteBookMainView(CanViewMixin, ListView):
 | 
			
		||||
    """Choose which launderette to book."""
 | 
			
		||||
 | 
			
		||||
    model = Launderette
 | 
			
		||||
    template_name = "launderette/launderette_book_choose.jinja"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class LaunderetteBookView(CanViewMixin, DetailView):
 | 
			
		||||
    """Display the launderette schedule."""
 | 
			
		||||
 | 
			
		||||
    model = Launderette
 | 
			
		||||
    pk_url_kwarg = "launderette_id"
 | 
			
		||||
    template_name = "launderette/launderette_book.jinja"
 | 
			
		||||
 | 
			
		||||
    def get(self, request, *args, **kwargs):
 | 
			
		||||
        self.slot_type = "BOTH"
 | 
			
		||||
        self.machines = {}
 | 
			
		||||
        return super().get(request, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def post(self, request, *args, **kwargs):
 | 
			
		||||
        self.slot_type = "BOTH"
 | 
			
		||||
        self.machines = {}
 | 
			
		||||
        with transaction.atomic():
 | 
			
		||||
            self.object = self.get_object()
 | 
			
		||||
            if "slot_type" in request.POST:
 | 
			
		||||
                self.slot_type = request.POST["slot_type"]
 | 
			
		||||
            if "slot" in request.POST and request.user.is_authenticated:
 | 
			
		||||
                self.subscriber = request.user
 | 
			
		||||
                if self.subscriber.is_subscribed:
 | 
			
		||||
                    self.date = dateparse.parse_datetime(request.POST["slot"]).replace(
 | 
			
		||||
                        tzinfo=tz.utc
 | 
			
		||||
                    )
 | 
			
		||||
                    if self.slot_type in ["WASHING", "DRYING"]:
 | 
			
		||||
                        if self.check_slot(self.slot_type):
 | 
			
		||||
                            Slot(
 | 
			
		||||
                                user=self.subscriber,
 | 
			
		||||
                                start_date=self.date,
 | 
			
		||||
                                machine=self.machines[self.slot_type],
 | 
			
		||||
                                type=self.slot_type,
 | 
			
		||||
                            ).save()
 | 
			
		||||
                    elif self.check_slot("WASHING") and self.check_slot(
 | 
			
		||||
                        "DRYING", self.date + timedelta(hours=1)
 | 
			
		||||
                    ):
 | 
			
		||||
                        Slot(
 | 
			
		||||
                            user=self.subscriber,
 | 
			
		||||
                            start_date=self.date,
 | 
			
		||||
                            machine=self.machines["WASHING"],
 | 
			
		||||
                            type="WASHING",
 | 
			
		||||
                        ).save()
 | 
			
		||||
                        Slot(
 | 
			
		||||
                            user=self.subscriber,
 | 
			
		||||
                            start_date=self.date + timedelta(hours=1),
 | 
			
		||||
                            machine=self.machines["DRYING"],
 | 
			
		||||
                            type="DRYING",
 | 
			
		||||
                        ).save()
 | 
			
		||||
        return super().get(request, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def check_slot(self, machine_type, date=None):
 | 
			
		||||
        if date is None:
 | 
			
		||||
            date = self.date
 | 
			
		||||
        for m in self.object.machines.filter(is_working=True, type=machine_type):
 | 
			
		||||
            slot = Slot.objects.filter(start_date=date, machine=m).first()
 | 
			
		||||
            if slot is None:
 | 
			
		||||
                self.machines[machine_type] = m
 | 
			
		||||
                return True
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def date_iterator(startDate, endDate, delta=timedelta(days=1)):
 | 
			
		||||
        currentDate = startDate
 | 
			
		||||
        while currentDate < endDate:
 | 
			
		||||
            yield currentDate
 | 
			
		||||
            currentDate += delta
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        """Add page to the context."""
 | 
			
		||||
        kwargs = super().get_context_data(**kwargs)
 | 
			
		||||
        kwargs["planning"] = OrderedDict()
 | 
			
		||||
        kwargs["slot_type"] = self.slot_type
 | 
			
		||||
        start_date = datetime.now().replace(
 | 
			
		||||
            hour=0, minute=0, second=0, microsecond=0, tzinfo=tz.utc
 | 
			
		||||
        )
 | 
			
		||||
        for date in LaunderetteBookView.date_iterator(
 | 
			
		||||
            start_date, start_date + timedelta(days=6), timedelta(days=1)
 | 
			
		||||
        ):
 | 
			
		||||
            kwargs["planning"][date] = []
 | 
			
		||||
            for h in LaunderetteBookView.date_iterator(
 | 
			
		||||
                date, date + timedelta(days=1), timedelta(hours=1)
 | 
			
		||||
            ):
 | 
			
		||||
                free = False
 | 
			
		||||
                if (
 | 
			
		||||
                    (
 | 
			
		||||
                        self.slot_type == "BOTH"
 | 
			
		||||
                        and self.check_slot("WASHING", h)
 | 
			
		||||
                        and self.check_slot("DRYING", h + timedelta(hours=1))
 | 
			
		||||
                    )
 | 
			
		||||
                    or (self.slot_type == "WASHING" and self.check_slot("WASHING", h))
 | 
			
		||||
                    or (self.slot_type == "DRYING" and self.check_slot("DRYING", h))
 | 
			
		||||
                ):
 | 
			
		||||
                    free = True
 | 
			
		||||
                if free and datetime.now().replace(tzinfo=tz.utc) < h:
 | 
			
		||||
                    kwargs["planning"][date].append(h)
 | 
			
		||||
                else:
 | 
			
		||||
                    kwargs["planning"][date].append(None)
 | 
			
		||||
        return kwargs
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SlotDeleteView(CanEditPropMixin, DeleteView):
 | 
			
		||||
    """Delete a slot."""
 | 
			
		||||
 | 
			
		||||
    model = Slot
 | 
			
		||||
    pk_url_kwarg = "slot_id"
 | 
			
		||||
    template_name = "core/delete_confirm.jinja"
 | 
			
		||||
 | 
			
		||||
    def get_success_url(self):
 | 
			
		||||
        return self.request.user.get_absolute_url()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# For admins
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class LaunderetteListView(CanEditPropMixin, ListView):
 | 
			
		||||
    """Choose which launderette to administer."""
 | 
			
		||||
 | 
			
		||||
    model = Launderette
 | 
			
		||||
    template_name = "launderette/launderette_list.jinja"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class LaunderetteEditView(CanEditPropMixin, UpdateView):
 | 
			
		||||
    """Edit a launderette."""
 | 
			
		||||
 | 
			
		||||
    model = Launderette
 | 
			
		||||
    pk_url_kwarg = "launderette_id"
 | 
			
		||||
    fields = ["name"]
 | 
			
		||||
    template_name = "core/edit.jinja"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class LaunderetteCreateView(PermissionRequiredMixin, CreateView):
 | 
			
		||||
    """Create a new launderette."""
 | 
			
		||||
 | 
			
		||||
    model = Launderette
 | 
			
		||||
    fields = ["name"]
 | 
			
		||||
    template_name = "core/create.jinja"
 | 
			
		||||
    permission_required = "launderette.add_launderette"
 | 
			
		||||
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        club = Club.objects.get(id=settings.SITH_LAUNDERETTE_CLUB_ID)
 | 
			
		||||
        c = Counter(name=form.instance.name, club=club, type="OFFICE")
 | 
			
		||||
        c.save()
 | 
			
		||||
        form.instance.counter = c
 | 
			
		||||
        return super().form_valid(form)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ManageTokenForm(forms.Form):
 | 
			
		||||
    action = forms.ChoiceField(
 | 
			
		||||
        choices=[("BACK", _("Back")), ("ADD", _("Add")), ("DEL", _("Delete"))],
 | 
			
		||||
        initial="BACK",
 | 
			
		||||
        label=_("Action"),
 | 
			
		||||
        widget=forms.RadioSelect,
 | 
			
		||||
    )
 | 
			
		||||
    token_type = forms.ChoiceField(
 | 
			
		||||
        choices=settings.SITH_LAUNDERETTE_MACHINE_TYPES,
 | 
			
		||||
        label=_("Type"),
 | 
			
		||||
        initial="WASHING",
 | 
			
		||||
        widget=forms.RadioSelect,
 | 
			
		||||
    )
 | 
			
		||||
    tokens = forms.CharField(
 | 
			
		||||
        max_length=512,
 | 
			
		||||
        widget=forms.widgets.Textarea,
 | 
			
		||||
        label=_("Tokens, separated by spaces"),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def process(self, launderette):
 | 
			
		||||
        cleaned_data = self.cleaned_data
 | 
			
		||||
        token_list = cleaned_data["tokens"].strip(" \n\r").split(" ")
 | 
			
		||||
        token_type = cleaned_data["token_type"]
 | 
			
		||||
        self.data = {}
 | 
			
		||||
 | 
			
		||||
        if cleaned_data["action"] not in ["BACK", "ADD", "DEL"]:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        tokens = list(
 | 
			
		||||
            Token.objects.filter(
 | 
			
		||||
                launderette=launderette, type=token_type, name__in=token_list
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        existing_names = {t.name for t in tokens}
 | 
			
		||||
        if cleaned_data["action"] in ["BACK", "DEL"]:
 | 
			
		||||
            for t in set(token_list) - existing_names:
 | 
			
		||||
                self.add_error(
 | 
			
		||||
                    None,
 | 
			
		||||
                    _("Token %(token_name)s does not exists") % {"token_name": t},
 | 
			
		||||
                )
 | 
			
		||||
        if cleaned_data["action"] == "BACK":
 | 
			
		||||
            Token.objects.filter(id__in=[t.id for t in tokens]).update(
 | 
			
		||||
                borrow_date=None, user=None
 | 
			
		||||
            )
 | 
			
		||||
        elif cleaned_data["action"] == "DEL":
 | 
			
		||||
            Token.objects.filter(id__in=[t.id for t in tokens]).delete()
 | 
			
		||||
        elif cleaned_data["action"] == "ADD":
 | 
			
		||||
            for name in existing_names:
 | 
			
		||||
                self.add_error(
 | 
			
		||||
                    None,
 | 
			
		||||
                    _("Token %(token_name)s already exists") % {"token_name": name},
 | 
			
		||||
                )
 | 
			
		||||
            for t in token_list:
 | 
			
		||||
                if t == "":
 | 
			
		||||
                    self.add_error(None, _("Token name can not be blank"))
 | 
			
		||||
                else:
 | 
			
		||||
                    Token(launderette=launderette, type=token_type, name=t).save()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class LaunderetteAdminView(CanEditPropMixin, BaseFormView, DetailView):
 | 
			
		||||
    """The admin page of the launderette."""
 | 
			
		||||
 | 
			
		||||
    model = Launderette
 | 
			
		||||
    pk_url_kwarg = "launderette_id"
 | 
			
		||||
    template_name = "launderette/launderette_admin.jinja"
 | 
			
		||||
    form_class = ManageTokenForm
 | 
			
		||||
 | 
			
		||||
    def get(self, request, *args, **kwargs):
 | 
			
		||||
        self.object = self.get_object()
 | 
			
		||||
        return super().get(request, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def post(self, request, *args, **kwargs):
 | 
			
		||||
        self.object = self.get_object()
 | 
			
		||||
        return super().post(request, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        """We handle here the redirection, passing the user id of the asked customer."""
 | 
			
		||||
        form.process(self.object)
 | 
			
		||||
        if form.is_valid():
 | 
			
		||||
            return super().form_valid(form)
 | 
			
		||||
        else:
 | 
			
		||||
            return super().form_invalid(form)
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        """We handle here the login form for the barman."""
 | 
			
		||||
        kwargs = super().get_context_data(**kwargs)
 | 
			
		||||
        if self.request.method == "GET":
 | 
			
		||||
            kwargs["form"] = self.get_form()
 | 
			
		||||
        return kwargs
 | 
			
		||||
 | 
			
		||||
    def get_success_url(self):
 | 
			
		||||
        return reverse_lazy(
 | 
			
		||||
            "launderette:launderette_admin", args=self.args, kwargs=self.kwargs
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GetLaunderetteUserForm(GetUserForm):
 | 
			
		||||
    def clean(self):
 | 
			
		||||
        cleaned_data = super().clean()
 | 
			
		||||
        sub = cleaned_data["user"]
 | 
			
		||||
        if sub.slots.all().count() <= 0:
 | 
			
		||||
            raise forms.ValidationError(_("User has booked no slot"))
 | 
			
		||||
        return cleaned_data
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class LaunderetteMainClickView(CanEditMixin, BaseFormView, DetailView):
 | 
			
		||||
    """The click page of the launderette."""
 | 
			
		||||
 | 
			
		||||
    model = Launderette
 | 
			
		||||
    pk_url_kwarg = "launderette_id"
 | 
			
		||||
    template_name = "counter/counter_main.jinja"
 | 
			
		||||
    form_class = GetLaunderetteUserForm  # Form to enter a client code and get the corresponding user id
 | 
			
		||||
 | 
			
		||||
    def get(self, request, *args, **kwargs):
 | 
			
		||||
        self.object = self.get_object()
 | 
			
		||||
        return super().get(request, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def post(self, request, *args, **kwargs):
 | 
			
		||||
        self.object = self.get_object()
 | 
			
		||||
        return super().post(request, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        """We handle here the redirection, passing the user id of the asked customer."""
 | 
			
		||||
        self.kwargs["user_id"] = form.cleaned_data["user_id"]
 | 
			
		||||
        return super().form_valid(form)
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        """We handle here the login form for the barman."""
 | 
			
		||||
        kwargs = super().get_context_data(**kwargs)
 | 
			
		||||
        kwargs["counter"] = self.object.counter
 | 
			
		||||
        kwargs["form"] = self.get_form()
 | 
			
		||||
        kwargs["barmen"] = [self.request.user]
 | 
			
		||||
        if "last_basket" in self.request.session:
 | 
			
		||||
            kwargs["last_basket"] = self.request.session.pop("last_basket", None)
 | 
			
		||||
            kwargs["last_customer"] = self.request.session.pop("last_customer", None)
 | 
			
		||||
            kwargs["last_total"] = self.request.session.pop("last_total", None)
 | 
			
		||||
            kwargs["new_customer_amount"] = self.request.session.pop(
 | 
			
		||||
                "new_customer_amount", None
 | 
			
		||||
            )
 | 
			
		||||
        return kwargs
 | 
			
		||||
 | 
			
		||||
    def get_success_url(self):
 | 
			
		||||
        return reverse_lazy("launderette:click", args=self.args, kwargs=self.kwargs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ClickTokenForm(forms.BaseForm):
 | 
			
		||||
    def clean(self):
 | 
			
		||||
        with transaction.atomic():
 | 
			
		||||
            operator = User.objects.filter(id=self.operator_id).first()
 | 
			
		||||
            customer = Customer.objects.filter(user__id=self.subscriber_id).first()
 | 
			
		||||
            counter = Counter.objects.filter(id=self.counter_id).first()
 | 
			
		||||
            subscriber = customer.user
 | 
			
		||||
            self.last_basket = {
 | 
			
		||||
                "last_basket": [],
 | 
			
		||||
                "last_customer": customer.user.get_display_name(),
 | 
			
		||||
            }
 | 
			
		||||
            total = 0
 | 
			
		||||
            for k, t in self.cleaned_data.items():
 | 
			
		||||
                if t is not None:
 | 
			
		||||
                    slot_id = int(k[5:])
 | 
			
		||||
                    slot = Slot.objects.filter(id=slot_id).first()
 | 
			
		||||
                    slot.token = t
 | 
			
		||||
                    slot.save()
 | 
			
		||||
                    t.user = subscriber
 | 
			
		||||
                    t.borrow_date = datetime.now().replace(tzinfo=tz.utc)
 | 
			
		||||
                    t.save()
 | 
			
		||||
                    price = settings.SITH_LAUNDERETTE_PRICES[t.type]
 | 
			
		||||
                    s = Selling(
 | 
			
		||||
                        label="Jeton " + t.get_type_display() + " N°" + t.name,
 | 
			
		||||
                        club=counter.club,
 | 
			
		||||
                        product=None,
 | 
			
		||||
                        counter=counter,
 | 
			
		||||
                        unit_price=price,
 | 
			
		||||
                        quantity=1,
 | 
			
		||||
                        seller=operator,
 | 
			
		||||
                        customer=customer,
 | 
			
		||||
                    )
 | 
			
		||||
                    s.save()
 | 
			
		||||
                    total += price
 | 
			
		||||
                    self.last_basket["last_basket"].append(
 | 
			
		||||
                        "Jeton " + t.get_type_display() + " N°" + t.name
 | 
			
		||||
                    )
 | 
			
		||||
            self.last_basket["new_customer_amount"] = str(customer.amount)
 | 
			
		||||
            self.last_basket["last_total"] = str(total)
 | 
			
		||||
        return self.cleaned_data
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class LaunderetteClickView(CanEditMixin, DetailView, BaseFormView):
 | 
			
		||||
    """The click page of the launderette."""
 | 
			
		||||
 | 
			
		||||
    model = Launderette
 | 
			
		||||
    pk_url_kwarg = "launderette_id"
 | 
			
		||||
    template_name = "launderette/launderette_click.jinja"
 | 
			
		||||
 | 
			
		||||
    def get_form_class(self):
 | 
			
		||||
        fields = OrderedDict()
 | 
			
		||||
        kwargs = {}
 | 
			
		||||
 | 
			
		||||
        def clean_field_factory(field_name, slot):
 | 
			
		||||
            def clean_field(self2):
 | 
			
		||||
                t_name = str(self2.data[field_name])
 | 
			
		||||
                if t_name != "":
 | 
			
		||||
                    t = Token.objects.filter(
 | 
			
		||||
                        name=str(self2.data[field_name]),
 | 
			
		||||
                        type=slot.type,
 | 
			
		||||
                        launderette=self.object,
 | 
			
		||||
                        user=None,
 | 
			
		||||
                    ).first()
 | 
			
		||||
                    if t is None:
 | 
			
		||||
                        raise forms.ValidationError(_("Token not found"))
 | 
			
		||||
                    return t
 | 
			
		||||
 | 
			
		||||
            return clean_field
 | 
			
		||||
 | 
			
		||||
        for s in self.subscriber.slots.filter(
 | 
			
		||||
            token=None, start_date__gte=timezone.now().replace(tzinfo=None)
 | 
			
		||||
        ).all():
 | 
			
		||||
            field_name = "slot-%s" % (str(s.id))
 | 
			
		||||
            fields[field_name] = forms.CharField(
 | 
			
		||||
                max_length=5,
 | 
			
		||||
                required=False,
 | 
			
		||||
                label="%s - %s"
 | 
			
		||||
                % (
 | 
			
		||||
                    s.get_type_display(),
 | 
			
		||||
                    defaultfilters.date(s.start_date, "j N Y H:i"),
 | 
			
		||||
                ),
 | 
			
		||||
            )
 | 
			
		||||
            # XXX l10n settings.DATETIME_FORMAT didn't work here :/
 | 
			
		||||
            kwargs["clean_" + field_name] = clean_field_factory(field_name, s)
 | 
			
		||||
        kwargs["subscriber_id"] = self.subscriber.id
 | 
			
		||||
        kwargs["counter_id"] = self.object.counter.id
 | 
			
		||||
        kwargs["operator_id"] = self.operator.id
 | 
			
		||||
        kwargs["base_fields"] = fields
 | 
			
		||||
        return type("ClickForm", (ClickTokenForm,), kwargs)
 | 
			
		||||
 | 
			
		||||
    def get(self, request, *args, **kwargs):
 | 
			
		||||
        """Simple get view."""
 | 
			
		||||
        self.customer = Customer.objects.filter(user__id=self.kwargs["user_id"]).first()
 | 
			
		||||
        self.subscriber = self.customer.user
 | 
			
		||||
        self.operator = request.user
 | 
			
		||||
        return super().get(request, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def post(self, request, *args, **kwargs):
 | 
			
		||||
        """Handle the many possibilities of the post request."""
 | 
			
		||||
        self.object = self.get_object()
 | 
			
		||||
        self.customer = Customer.objects.filter(user__id=self.kwargs["user_id"]).first()
 | 
			
		||||
        self.subscriber = self.customer.user
 | 
			
		||||
        self.operator = request.user
 | 
			
		||||
        return super().post(request, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        """We handle here the redirection, passing the user id of the asked customer."""
 | 
			
		||||
        self.request.session.update(form.last_basket)
 | 
			
		||||
        return super().form_valid(form)
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        """We handle here the login form for the barman."""
 | 
			
		||||
        kwargs = super().get_context_data(**kwargs)
 | 
			
		||||
        if "form" not in kwargs:
 | 
			
		||||
            kwargs["form"] = self.get_form()
 | 
			
		||||
        kwargs["counter"] = self.object.counter
 | 
			
		||||
        kwargs["customer"] = self.customer
 | 
			
		||||
        return kwargs
 | 
			
		||||
 | 
			
		||||
    def get_success_url(self):
 | 
			
		||||
        self.kwargs.pop("user_id", None)
 | 
			
		||||
        return reverse_lazy(
 | 
			
		||||
            "launderette:main_click", args=self.args, kwargs=self.kwargs
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MachineEditView(CanEditPropMixin, UpdateView):
 | 
			
		||||
    """Edit a machine."""
 | 
			
		||||
 | 
			
		||||
    model = Machine
 | 
			
		||||
    pk_url_kwarg = "machine_id"
 | 
			
		||||
    fields = ["name", "launderette", "type", "is_working"]
 | 
			
		||||
    template_name = "core/edit.jinja"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MachineDeleteView(CanEditPropMixin, DeleteView):
 | 
			
		||||
    """Edit a machine."""
 | 
			
		||||
 | 
			
		||||
    model = Machine
 | 
			
		||||
    pk_url_kwarg = "machine_id"
 | 
			
		||||
    template_name = "core/delete_confirm.jinja"
 | 
			
		||||
    success_url = reverse_lazy("launderette:launderette_list")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MachineCreateView(PermissionRequiredMixin, CreateView):
 | 
			
		||||
    """Create a new machine."""
 | 
			
		||||
 | 
			
		||||
    model = Machine
 | 
			
		||||
    fields = ["name", "launderette", "type"]
 | 
			
		||||
    template_name = "core/create.jinja"
 | 
			
		||||
    permission_required = "launderette.add_machine"
 | 
			
		||||
 | 
			
		||||
    def get_initial(self):
 | 
			
		||||
        ret = super().get_initial()
 | 
			
		||||
        if "launderette" in self.request.GET:
 | 
			
		||||
            obj = Launderette.objects.filter(
 | 
			
		||||
                id=int(self.request.GET["launderette"])
 | 
			
		||||
            ).first()
 | 
			
		||||
            if obj is not None:
 | 
			
		||||
                ret["launderette"] = obj.id
 | 
			
		||||
        return ret
 | 
			
		||||
@@ -6,7 +6,7 @@
 | 
			
		||||
msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Report-Msgid-Bugs-To: \n"
 | 
			
		||||
"POT-Creation-Date: 2025-04-14 01:16+0200\n"
 | 
			
		||||
"POT-Creation-Date: 2025-04-15 23:39+0200\n"
 | 
			
		||||
"PO-Revision-Date: 2016-07-18\n"
 | 
			
		||||
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
 | 
			
		||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
 | 
			
		||||
@@ -1764,8 +1764,8 @@ msgstr "Photos"
 | 
			
		||||
 | 
			
		||||
#: core/templates/core/base/navbar.jinja counter/models.py
 | 
			
		||||
#: counter/templates/counter/counter_list.jinja
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_main.jinja
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_payment_result.jinja sith/settings.py
 | 
			
		||||
msgid "Eboutic"
 | 
			
		||||
msgstr "Eboutic"
 | 
			
		||||
@@ -2882,6 +2882,30 @@ msgstr ""
 | 
			
		||||
msgid "Refound this account"
 | 
			
		||||
msgstr "Rembourser ce compte"
 | 
			
		||||
 | 
			
		||||
#: counter/forms.py
 | 
			
		||||
msgid "The selected product isn't available for this user"
 | 
			
		||||
msgstr "Le produit sélectionné n'est pas disponnible pour cet utilisateur"
 | 
			
		||||
 | 
			
		||||
#: counter/forms.py
 | 
			
		||||
msgid "Submitted basket is invalid"
 | 
			
		||||
msgstr "Le panier envoyé est invalide"
 | 
			
		||||
 | 
			
		||||
#: counter/forms.py
 | 
			
		||||
msgid "Duplicated product entries."
 | 
			
		||||
msgstr "Saisie de produit dupliquée"
 | 
			
		||||
 | 
			
		||||
#: counter/forms.py counter/models.py
 | 
			
		||||
msgid "Not enough money"
 | 
			
		||||
msgstr "Solde insuffisant"
 | 
			
		||||
 | 
			
		||||
#: counter/forms.py
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid ""
 | 
			
		||||
"This user have reached his recording limit for the following products : %s"
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Cet utilisateur a atteint sa limite de déconsigne pour les produits "
 | 
			
		||||
"suivants : %s"
 | 
			
		||||
 | 
			
		||||
#: counter/management/commands/dump_accounts.py
 | 
			
		||||
msgid "Your AE account has been emptied"
 | 
			
		||||
msgstr "Votre compte AE a été vidé"
 | 
			
		||||
@@ -2906,10 +2930,6 @@ msgstr "client"
 | 
			
		||||
msgid "customers"
 | 
			
		||||
msgstr "clients"
 | 
			
		||||
 | 
			
		||||
#: counter/models.py counter/views/click.py
 | 
			
		||||
msgid "Not enough money"
 | 
			
		||||
msgstr "Solde insuffisant"
 | 
			
		||||
 | 
			
		||||
#: counter/models.py
 | 
			
		||||
msgid "First name"
 | 
			
		||||
msgstr "Prénom"
 | 
			
		||||
@@ -3278,7 +3298,7 @@ msgid "Go"
 | 
			
		||||
msgstr "Valider"
 | 
			
		||||
 | 
			
		||||
#: counter/templates/counter/counter_click.jinja
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
 | 
			
		||||
msgid "Basket: "
 | 
			
		||||
msgstr "Panier : "
 | 
			
		||||
 | 
			
		||||
@@ -3672,26 +3692,6 @@ msgstr "Montant du chèque"
 | 
			
		||||
msgid "Check quantity"
 | 
			
		||||
msgstr "Nombre de chèque"
 | 
			
		||||
 | 
			
		||||
#: counter/views/click.py
 | 
			
		||||
msgid "The selected product isn't available for this user"
 | 
			
		||||
msgstr "Le produit sélectionné n'est pas disponnible pour cet utilisateur"
 | 
			
		||||
 | 
			
		||||
#: counter/views/click.py
 | 
			
		||||
msgid "Submitted basket is invalid"
 | 
			
		||||
msgstr "Le panier envoyé est invalide"
 | 
			
		||||
 | 
			
		||||
#: counter/views/click.py
 | 
			
		||||
msgid "Duplicated product entries."
 | 
			
		||||
msgstr "Saisie de produit dupliquée"
 | 
			
		||||
 | 
			
		||||
#: counter/views/click.py
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid ""
 | 
			
		||||
"This user have reached his recording limit for the following products : %s"
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Cet utilisateur a atteint sa limite de déconsigne pour les produits "
 | 
			
		||||
"suivants : %s"
 | 
			
		||||
 | 
			
		||||
#: counter/views/eticket.py
 | 
			
		||||
msgid "people(s)"
 | 
			
		||||
msgstr "personne(s)"
 | 
			
		||||
@@ -3729,19 +3729,6 @@ msgstr "Types de produit"
 | 
			
		||||
msgid "%(name)s has no registered student card"
 | 
			
		||||
msgstr "%(name)s n'a pas de carte étudiante enregistrée"
 | 
			
		||||
 | 
			
		||||
#: eboutic/forms.py
 | 
			
		||||
msgid "The request was badly formatted."
 | 
			
		||||
msgstr "La requête a été mal formatée."
 | 
			
		||||
 | 
			
		||||
#: eboutic/forms.py
 | 
			
		||||
msgid "Your basket is empty."
 | 
			
		||||
msgstr "Votre panier est vide"
 | 
			
		||||
 | 
			
		||||
#: eboutic/forms.py
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "%(name)s : this product does not exist or may no longer be available."
 | 
			
		||||
msgstr "%(name)s : ce produit n'existe pas ou n'est peut-être plus disponible."
 | 
			
		||||
 | 
			
		||||
#: eboutic/models.py
 | 
			
		||||
msgid "validated"
 | 
			
		||||
msgstr "validé"
 | 
			
		||||
@@ -3779,15 +3766,44 @@ msgstr "Informations de facturation"
 | 
			
		||||
msgid "Validate"
 | 
			
		||||
msgstr "Valider"
 | 
			
		||||
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
 | 
			
		||||
msgid "Basket state"
 | 
			
		||||
msgstr "État du panier"
 | 
			
		||||
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_main.jinja
 | 
			
		||||
msgid "Basket amount: "
 | 
			
		||||
msgstr "Valeur du panier : "
 | 
			
		||||
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_main.jinja
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
 | 
			
		||||
msgid "Current account amount: "
 | 
			
		||||
msgstr "Solde actuel : "
 | 
			
		||||
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_main.jinja
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
 | 
			
		||||
msgid "Basket amount: "
 | 
			
		||||
msgstr "Valeur du panier : "
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
 | 
			
		||||
msgid "Remaining account amount: "
 | 
			
		||||
msgstr "Solde restant : "
 | 
			
		||||
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
 | 
			
		||||
msgid "Pay with credit card"
 | 
			
		||||
msgstr "Payer avec une carte bancaire"
 | 
			
		||||
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
 | 
			
		||||
msgid ""
 | 
			
		||||
"AE account payment disabled because your basket contains refilling items."
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Paiement par compte AE désactivé parce que votre panier contient des bons de "
 | 
			
		||||
"rechargement."
 | 
			
		||||
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
 | 
			
		||||
msgid ""
 | 
			
		||||
"AE account payment disabled because you do not have enough money remaining."
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Paiement par compte AE désactivé parce que votre solde est insuffisant."
 | 
			
		||||
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
 | 
			
		||||
msgid "Pay with Sith account"
 | 
			
		||||
msgstr "Payer avec un compte AE"
 | 
			
		||||
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_main.jinja
 | 
			
		||||
msgid "Clear"
 | 
			
		||||
@@ -3815,12 +3831,13 @@ msgstr "Partenariat Eurockéennes 2025"
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_main.jinja
 | 
			
		||||
msgid ""
 | 
			
		||||
"Our partner uses Weezevent to sell tickets. Weezevent may collect user info "
 | 
			
		||||
"according to it's own privacy policy. By clicking the accept button you "
 | 
			
		||||
"according to its own privacy policy. By clicking the accept button you "
 | 
			
		||||
"consent to their terms of services."
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut collecter des informatinos utilisateur "
 | 
			
		||||
"conformément à sa propre politique de confidentialité. En cliquant sur le bouton d'acceptation vous "
 | 
			
		||||
"consentez à leurs termes de service."
 | 
			
		||||
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut "
 | 
			
		||||
"collecter des informations utilisateur conformément à sa propre politique de "
 | 
			
		||||
"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à "
 | 
			
		||||
"leurs termes de service."
 | 
			
		||||
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_main.jinja
 | 
			
		||||
msgid "Privacy policy"
 | 
			
		||||
@@ -3852,35 +3869,6 @@ msgstr ""
 | 
			
		||||
msgid "There are no items available for sale"
 | 
			
		||||
msgstr "Aucun article n'est disponible à la vente"
 | 
			
		||||
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
 | 
			
		||||
msgid "Basket state"
 | 
			
		||||
msgstr "État du panier"
 | 
			
		||||
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
 | 
			
		||||
msgid "Remaining account amount: "
 | 
			
		||||
msgstr "Solde restant : "
 | 
			
		||||
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
 | 
			
		||||
msgid "Pay with credit card"
 | 
			
		||||
msgstr "Payer avec une carte bancaire"
 | 
			
		||||
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
 | 
			
		||||
msgid ""
 | 
			
		||||
"AE account payment disabled because your basket contains refilling items."
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Paiement par compte AE désactivé parce que votre panier contient des bons de "
 | 
			
		||||
"rechargement."
 | 
			
		||||
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
 | 
			
		||||
msgid ""
 | 
			
		||||
"AE account payment disabled because you do not have enough money remaining."
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Paiement par compte AE désactivé parce que votre solde est insuffisant."
 | 
			
		||||
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
 | 
			
		||||
msgid "Pay with Sith account"
 | 
			
		||||
msgstr "Payer avec un compte AE"
 | 
			
		||||
 | 
			
		||||
#: eboutic/templates/eboutic/eboutic_payment_result.jinja
 | 
			
		||||
msgid "Payment successful"
 | 
			
		||||
msgstr "Le paiement a été effectué"
 | 
			
		||||
@@ -3893,6 +3881,10 @@ msgstr "Le paiement a échoué"
 | 
			
		||||
msgid "Return to eboutic"
 | 
			
		||||
msgstr "Retourner à l'eboutic"
 | 
			
		||||
 | 
			
		||||
#: eboutic/views.py
 | 
			
		||||
msgid "Your basket is empty"
 | 
			
		||||
msgstr "Votre panier est vide"
 | 
			
		||||
 | 
			
		||||
#: eboutic/views.py
 | 
			
		||||
msgid "Billing info registration success"
 | 
			
		||||
msgstr "Informations de facturation enregistrées"
 | 
			
		||||
@@ -3916,6 +3908,10 @@ msgstr ""
 | 
			
		||||
"souhaitez payer par carte, vous devez rajouter un numéro de téléphone aux "
 | 
			
		||||
"données que vous aviez déjà fourni."
 | 
			
		||||
 | 
			
		||||
#: eboutic/views.py
 | 
			
		||||
msgid "You can't buy a refilling with sith money"
 | 
			
		||||
msgstr "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith"
 | 
			
		||||
 | 
			
		||||
#: election/models.py
 | 
			
		||||
msgid "start candidature"
 | 
			
		||||
msgstr "début des candidatures"
 | 
			
		||||
 
 | 
			
		||||
@@ -37,6 +37,10 @@ msgstr "Supprimer"
 | 
			
		||||
msgid "Copy calendar link"
 | 
			
		||||
msgstr "Copier le lien du calendrier"
 | 
			
		||||
 | 
			
		||||
#: com/static/bundled/com/components/ics-calendar-index.ts
 | 
			
		||||
msgid "How to use calendar link"
 | 
			
		||||
msgstr "Comment utiliser le lien du calendrier"
 | 
			
		||||
 | 
			
		||||
#: com/static/bundled/com/components/ics-calendar-index.ts
 | 
			
		||||
msgid "Link copied"
 | 
			
		||||
msgstr "Lien copié"
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										11
									
								
								manage.py
									
									
									
									
									
								
							
							
						
						
									
										11
									
								
								manage.py
									
									
									
									
									
								
							@@ -18,10 +18,10 @@ import logging
 | 
			
		||||
import os
 | 
			
		||||
import sys
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.utils.autoreload import DJANGO_AUTORELOAD_ENV
 | 
			
		||||
 | 
			
		||||
from sith.composer import start_composer, stop_composer
 | 
			
		||||
from sith.settings import PROCFILE_SERVICE
 | 
			
		||||
 | 
			
		||||
if __name__ == "__main__":
 | 
			
		||||
    logging.basicConfig(encoding="utf-8", level=logging.INFO)
 | 
			
		||||
@@ -30,8 +30,11 @@ if __name__ == "__main__":
 | 
			
		||||
 | 
			
		||||
    from django.core.management import execute_from_command_line
 | 
			
		||||
 | 
			
		||||
    if os.environ.get(DJANGO_AUTORELOAD_ENV) is None and PROCFILE_SERVICE is not None:
 | 
			
		||||
        start_composer(PROCFILE_SERVICE)
 | 
			
		||||
        _ = atexit.register(stop_composer, procfile=PROCFILE_SERVICE)
 | 
			
		||||
    if (
 | 
			
		||||
        os.environ.get(DJANGO_AUTORELOAD_ENV) is None
 | 
			
		||||
        and settings.PROCFILE_SERVICE is not None
 | 
			
		||||
    ):
 | 
			
		||||
        start_composer(settings.PROCFILE_SERVICE)
 | 
			
		||||
        _ = atexit.register(stop_composer, procfile=settings.PROCFILE_SERVICE)
 | 
			
		||||
 | 
			
		||||
    execute_from_command_line(sys.argv)
 | 
			
		||||
 
 | 
			
		||||
@@ -113,9 +113,6 @@ nav:
 | 
			
		||||
    - galaxy:
 | 
			
		||||
      - reference/galaxy/models.md
 | 
			
		||||
      - reference/galaxy/views.md
 | 
			
		||||
    - launderette:
 | 
			
		||||
      - reference/launderette/models.md
 | 
			
		||||
      - reference/launderette/views.md
 | 
			
		||||
    - matmat:
 | 
			
		||||
      - reference/matmat/models.md
 | 
			
		||||
      - reference/matmat/views.md
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										26
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										26
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							@@ -11,6 +11,7 @@
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@alpinejs/sort": "^3.14.7",
 | 
			
		||||
        "@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
 | 
			
		||||
        "@floating-ui/dom": "^1.6.13",
 | 
			
		||||
        "@fortawesome/fontawesome-free": "^6.6.0",
 | 
			
		||||
        "@fullcalendar/core": "^6.1.15",
 | 
			
		||||
        "@fullcalendar/daygrid": "^6.1.15",
 | 
			
		||||
@@ -2162,6 +2163,31 @@
 | 
			
		||||
        "node": ">=18"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@floating-ui/core": {
 | 
			
		||||
      "version": "1.6.9",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz",
 | 
			
		||||
      "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@floating-ui/utils": "^0.2.9"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@floating-ui/dom": {
 | 
			
		||||
      "version": "1.6.13",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz",
 | 
			
		||||
      "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@floating-ui/core": "^1.6.0",
 | 
			
		||||
        "@floating-ui/utils": "^0.2.9"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@floating-ui/utils": {
 | 
			
		||||
      "version": "0.2.9",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
 | 
			
		||||
      "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@fortawesome/fontawesome-free": {
 | 
			
		||||
      "version": "6.7.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.2.tgz",
 | 
			
		||||
 
 | 
			
		||||
@@ -38,6 +38,7 @@
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@alpinejs/sort": "^3.14.7",
 | 
			
		||||
    "@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
 | 
			
		||||
    "@floating-ui/dom": "^1.6.13",
 | 
			
		||||
    "@fortawesome/fontawesome-free": "^6.6.0",
 | 
			
		||||
    "@fullcalendar/core": "^6.1.15",
 | 
			
		||||
    "@fullcalendar/daygrid": "^6.1.15",
 | 
			
		||||
 
 | 
			
		||||
@@ -17,15 +17,15 @@ authors = [
 | 
			
		||||
    { name = "Vial", email = "robin.trioux@utbm.fr" },
 | 
			
		||||
]
 | 
			
		||||
license = { text = "GPL-3.0-only" }
 | 
			
		||||
requires-python = "<4.0,>=3.12"
 | 
			
		||||
requires-python = "<4.0,>=3.13"
 | 
			
		||||
dependencies = [
 | 
			
		||||
    "django<6.0.0,>=5.2.0",
 | 
			
		||||
    "django>=5.2.1,<6.0.0",
 | 
			
		||||
    "django-ninja<2.0.0,>=1.4.0",
 | 
			
		||||
    "django-ninja-extra<1.0.0,>=0.22.9",
 | 
			
		||||
    "Pillow<12.0.0,>=11.1.0",
 | 
			
		||||
    "mistune<4.0.0,>=3.1.3",
 | 
			
		||||
    "django-jinja<3.0.0,>=2.11.0",
 | 
			
		||||
    "cryptography<45.0.0,>=44.0.2",
 | 
			
		||||
    "cryptography>=45.0.3,<46.0.0",
 | 
			
		||||
    "django-phonenumber-field<9.0.0,>=8.1.0",
 | 
			
		||||
    "phonenumbers>=9.0.2,<10.0.0",
 | 
			
		||||
    "reportlab<5.0.0,>=4.3.1",
 | 
			
		||||
@@ -39,16 +39,19 @@ dependencies = [
 | 
			
		||||
    "jinja2<4.0.0,>=3.1.6",
 | 
			
		||||
    "django-countries<8.0.0,>=7.6.1",
 | 
			
		||||
    "dict2xml<2.0.0,>=1.7.6",
 | 
			
		||||
    "Sphinx<6,>=5",
 | 
			
		||||
    "Sphinx>=8.2.3,<9",
 | 
			
		||||
    "tomli<3.0.0,>=2.2.1",
 | 
			
		||||
    "django-honeypot",
 | 
			
		||||
    "pydantic-extra-types<3.0.0,>=2.10.3",
 | 
			
		||||
    "ical<10.0.0,>=9.1.0",
 | 
			
		||||
    "redis[hiredis]<6.0.0,>=5.2.1",
 | 
			
		||||
    "redis[hiredis]<6.0.0,>=5.3.0",
 | 
			
		||||
    "environs[django]<15.0.0,>=14.1.1",
 | 
			
		||||
    "requests>=2.32.3",
 | 
			
		||||
    "honcho>=2.0.0",
 | 
			
		||||
    "psutil>=7.0.0",
 | 
			
		||||
    "celery[redis]>=5.5.2",
 | 
			
		||||
    "django-celery-results>=2.5.1",
 | 
			
		||||
    "django-celery-beat>=2.7.0",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[project.urls]
 | 
			
		||||
@@ -57,13 +60,13 @@ documentation = "https://sith-ae.readthedocs.io/"
 | 
			
		||||
 | 
			
		||||
[dependency-groups]
 | 
			
		||||
prod = [
 | 
			
		||||
    "psycopg[c]<4.0.0,>=3.2.6",
 | 
			
		||||
    "psycopg[c]>=3.2.9,<4.0.0",
 | 
			
		||||
]
 | 
			
		||||
dev = [
 | 
			
		||||
    "django-debug-toolbar<5.0.0,>=4.4.6",
 | 
			
		||||
    "django-debug-toolbar>=5.2.0,<6.0.0",
 | 
			
		||||
    "ipython<10.0.0,>=9.0.2",
 | 
			
		||||
    "pre-commit<5.0.0,>=4.1.0",
 | 
			
		||||
    "ruff<1.0.0,>=0.11.1",
 | 
			
		||||
    "ruff>=0.11.11,<1.0.0",
 | 
			
		||||
    "djhtml<4.0.0,>=3.0.7",
 | 
			
		||||
    "faker<38.0.0,>=37.0.0",
 | 
			
		||||
    "rjsmin<2.0.0,>=1.2.4",
 | 
			
		||||
 
 | 
			
		||||
@@ -27,7 +27,9 @@ class AlbumCreateForm(forms.ModelForm):
 | 
			
		||||
            self.instance.moderator = owner
 | 
			
		||||
 | 
			
		||||
    def clean(self):
 | 
			
		||||
        if not self.instance.owner.can_edit(self.instance.parent):
 | 
			
		||||
        parent = self.cleaned_data["parent"]
 | 
			
		||||
        parent.__class__ = Album  # by default, parent is a SithFile
 | 
			
		||||
        if not self.instance.owner.can_edit(parent):
 | 
			
		||||
            raise ValidationError(_("You do not have the permission to do that"))
 | 
			
		||||
        return super().clean()
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -59,7 +59,7 @@
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      {% if is_sas_admin %}
 | 
			
		||||
      {% if album_create_fragment %}
 | 
			
		||||
        </form>
 | 
			
		||||
        <br>
 | 
			
		||||
        {{ album_create_fragment }}
 | 
			
		||||
 
 | 
			
		||||
@@ -15,12 +15,13 @@
 | 
			
		||||
from typing import Callable
 | 
			
		||||
 | 
			
		||||
import pytest
 | 
			
		||||
from bs4 import BeautifulSoup
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.core.cache import cache
 | 
			
		||||
from django.test import Client, TestCase
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from model_bakery import baker
 | 
			
		||||
from pytest_django.asserts import assertInHTML, assertRedirects
 | 
			
		||||
from pytest_django.asserts import assertHTMLEqual, assertInHTML, assertRedirects
 | 
			
		||||
 | 
			
		||||
from core.baker_recipes import old_subscriber_user, subscriber_user
 | 
			
		||||
from core.models import Group, User
 | 
			
		||||
@@ -41,16 +42,37 @@ from sas.models import Album, Picture
 | 
			
		||||
            User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
 | 
			
		||||
        ),
 | 
			
		||||
        lambda: baker.make(User),
 | 
			
		||||
        lambda: None,
 | 
			
		||||
    ],
 | 
			
		||||
)
 | 
			
		||||
def test_load_main_page(client: Client, user_factory: Callable[[], User]):
 | 
			
		||||
    """Just check that the SAS doesn't crash."""
 | 
			
		||||
    user = user_factory()
 | 
			
		||||
    client.force_login(user)
 | 
			
		||||
    if user is not None:
 | 
			
		||||
        client.force_login(user)
 | 
			
		||||
    res = client.get(reverse("sas:main"))
 | 
			
		||||
    assert res.status_code == 200
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.django_db
 | 
			
		||||
def test_main_page_no_form_for_regular_users(client: Client):
 | 
			
		||||
    """Test that subscribed users see no form on the sas main page"""
 | 
			
		||||
    client.force_login(subscriber_user.make())
 | 
			
		||||
    res = client.get(reverse("sas:main"))
 | 
			
		||||
    soup = BeautifulSoup(res.text, "lxml")
 | 
			
		||||
    forms = soup.find("main").find_all("form")
 | 
			
		||||
    assert len(forms) == 0
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.django_db
 | 
			
		||||
def test_main_page_content_anonymous(client: Client):
 | 
			
		||||
    """Test that public users see only an incentive to login"""
 | 
			
		||||
    res = client.get(reverse("sas:main"))
 | 
			
		||||
    soup = BeautifulSoup(res.text, "lxml")
 | 
			
		||||
    expected = "<h3>SAS</h3><p>Vous devez être connecté pour voir les photos.</p>"
 | 
			
		||||
    assertHTMLEqual(soup.find("main").decode_contents(), expected)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.django_db
 | 
			
		||||
def test_album_access_non_subscriber(client: Client):
 | 
			
		||||
    """Test that non-subscribers can only access albums where they are identified."""
 | 
			
		||||
@@ -67,6 +89,50 @@ def test_album_access_non_subscriber(client: Client):
 | 
			
		||||
    assert res.status_code == 200
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.django_db
 | 
			
		||||
class TestAlbumUpload:
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def assert_album_created(response, name, parent):
 | 
			
		||||
        assert response.headers.get("HX-Redirect", "") == parent.get_absolute_url()
 | 
			
		||||
        children = list(Album.objects.filter(parent=parent))
 | 
			
		||||
        assert len(children) == 1
 | 
			
		||||
        assert children[0].name == name
 | 
			
		||||
 | 
			
		||||
    def test_sas_admin(self, client: Client):
 | 
			
		||||
        user = baker.make(
 | 
			
		||||
            User, groups=[Group.objects.get(id=settings.SITH_GROUP_SAS_ADMIN_ID)]
 | 
			
		||||
        )
 | 
			
		||||
        album = baker.make(Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID)
 | 
			
		||||
        client.force_login(user)
 | 
			
		||||
        response = client.post(
 | 
			
		||||
            reverse("sas:album_create"), {"name": "new", "parent": album.id}
 | 
			
		||||
        )
 | 
			
		||||
        self.assert_album_created(response, "new", album)
 | 
			
		||||
 | 
			
		||||
    def test_non_admin_user_with_edit_rights_on_parent(self, client: Client):
 | 
			
		||||
        group = baker.make(Group)
 | 
			
		||||
        user = subscriber_user.make(groups=[group])
 | 
			
		||||
        album = baker.make(
 | 
			
		||||
            Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID, edit_groups=[group]
 | 
			
		||||
        )
 | 
			
		||||
        client.force_login(user)
 | 
			
		||||
        response = client.post(
 | 
			
		||||
            reverse("sas:album_create"), {"name": "new", "parent": album.id}
 | 
			
		||||
        )
 | 
			
		||||
        self.assert_album_created(response, "new", album)
 | 
			
		||||
 | 
			
		||||
    def test_permission_denied(self, client: Client):
 | 
			
		||||
        album = baker.make(Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID)
 | 
			
		||||
        client.force_login(subscriber_user.make())
 | 
			
		||||
        response = client.post(
 | 
			
		||||
            reverse("sas:album_create"), {"name": "new", "parent": album.id}
 | 
			
		||||
        )
 | 
			
		||||
        errors = BeautifulSoup(response.text, "lxml").find_all(class_="errorlist")
 | 
			
		||||
        assert len(errors) == 1
 | 
			
		||||
        assert errors[0].text == "Vous n'avez pas la permission de faire cela"
 | 
			
		||||
        assert not album.children.exists()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestSasModeration(TestCase):
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def setUpTestData(cls):
 | 
			
		||||
 
 | 
			
		||||
@@ -65,12 +65,16 @@ class SASMainView(UseFragmentsMixin, TemplateView):
 | 
			
		||||
    template_name = "sas/main.jinja"
 | 
			
		||||
 | 
			
		||||
    def get_fragments(self) -> dict[str, FragmentRenderer]:
 | 
			
		||||
        if not self.request.user.has_perm("sas.add_album"):
 | 
			
		||||
            return {}
 | 
			
		||||
        form_init = {"parent": SithFile.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID)}
 | 
			
		||||
        return {
 | 
			
		||||
            "album_create_fragment": AlbumCreateFragment.as_fragment(initial=form_init)
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    def get_fragment_data(self) -> dict[str, dict[str, Any]]:
 | 
			
		||||
        if not self.request.user.has_perm("sas.add_album"):
 | 
			
		||||
            return {}
 | 
			
		||||
        root_user = User.objects.get(pk=settings.SITH_ROOT_USER_ID)
 | 
			
		||||
        return {"album_create_fragment": {"owner": root_user}}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -12,3 +12,9 @@
 | 
			
		||||
# OR WITHIN THE LOCAL FILE "LICENSE"
 | 
			
		||||
#
 | 
			
		||||
#
 | 
			
		||||
 | 
			
		||||
# This will make sure the app is always imported when
 | 
			
		||||
# Django starts so that shared_task will use this app.
 | 
			
		||||
from .celery import app as celery_app
 | 
			
		||||
 | 
			
		||||
__all__ = ("celery_app",)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										17
									
								
								sith/celery.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								sith/celery.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
			
		||||
# Set the default Django settings module for the 'celery' program.
 | 
			
		||||
import os
 | 
			
		||||
 | 
			
		||||
from celery import Celery
 | 
			
		||||
 | 
			
		||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sith.settings")
 | 
			
		||||
 | 
			
		||||
app = Celery("sith")
 | 
			
		||||
 | 
			
		||||
# Using a string here means the worker doesn't have to serialize
 | 
			
		||||
# the configuration object to child processes.
 | 
			
		||||
# - namespace='CELERY' means all celery-related configuration keys
 | 
			
		||||
#   should have a `CELERY_` prefix.
 | 
			
		||||
app.config_from_object("django.conf:settings", namespace="CELERY")
 | 
			
		||||
 | 
			
		||||
# Load task modules from all registered Django apps.
 | 
			
		||||
app.autodiscover_tasks()
 | 
			
		||||
@@ -47,7 +47,7 @@ from sentry_sdk.integrations.django import DjangoIntegration
 | 
			
		||||
 | 
			
		||||
from .honeypot import custom_honeypot_error
 | 
			
		||||
 | 
			
		||||
env = Env()
 | 
			
		||||
env = Env(expand_vars=True)
 | 
			
		||||
env.read_env()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -106,6 +106,8 @@ INSTALLED_APPS = (
 | 
			
		||||
    "django_jinja",
 | 
			
		||||
    "ninja_extra",
 | 
			
		||||
    "haystack",
 | 
			
		||||
    "django_celery_results",
 | 
			
		||||
    "django_celery_beat",
 | 
			
		||||
    "captcha",
 | 
			
		||||
    "core",
 | 
			
		||||
    "club",
 | 
			
		||||
@@ -182,7 +184,6 @@ TEMPLATES = [
 | 
			
		||||
                "can_edit": "core.auth.mixins.can_edit",
 | 
			
		||||
                "can_view": "core.auth.mixins.can_view",
 | 
			
		||||
                "settings": "sith.settings",
 | 
			
		||||
                "Launderette": "launderette.models.Launderette",
 | 
			
		||||
                "Counter": "counter.models.Counter",
 | 
			
		||||
                "timezone": "django.utils.timezone",
 | 
			
		||||
                "get_sith": "com.views.sith",
 | 
			
		||||
@@ -336,6 +337,14 @@ EMAIL_BACKEND = env.str(
 | 
			
		||||
EMAIL_HOST = env.str("EMAIL_HOST", default="localhost")
 | 
			
		||||
EMAIL_PORT = env.int("EMAIL_PORT", default=25)
 | 
			
		||||
 | 
			
		||||
# Celery
 | 
			
		||||
CELERY_TIMEZONE = TIME_ZONE
 | 
			
		||||
CELERY_TASK_TRACK_STARTED = True
 | 
			
		||||
CELERY_TASK_TIME_LIMIT = 30 * 60
 | 
			
		||||
CELERY_BROKER_URL = env.str("TASK_BROKER_URL")
 | 
			
		||||
CELERY_RESULT_BACKEND = "django-db"
 | 
			
		||||
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
 | 
			
		||||
 | 
			
		||||
# Below this line, only Sith-specific variables are defined
 | 
			
		||||
 | 
			
		||||
SITH_URL = env.str("SITH_URL", default="127.0.0.1:8000")
 | 
			
		||||
@@ -658,10 +667,6 @@ with open(
 | 
			
		||||
) as f:
 | 
			
		||||
    SITH_EBOUTIC_PUB_KEY = f.read()
 | 
			
		||||
 | 
			
		||||
# Launderette variables
 | 
			
		||||
SITH_LAUNDERETTE_MACHINE_TYPES = [("WASHING", _("Washing")), ("DRYING", _("Drying"))]
 | 
			
		||||
SITH_LAUNDERETTE_PRICES = {"WASHING": 1.0, "DRYING": 0.75}
 | 
			
		||||
 | 
			
		||||
SITH_NOTIFICATIONS = [
 | 
			
		||||
    ("POSTER_MODERATION", _("A new poster needs to be moderated")),
 | 
			
		||||
    ("MAILING_MODERATION", _("A new mailing list needs to be moderated")),
 | 
			
		||||
@@ -718,7 +723,6 @@ if DEBUG:
 | 
			
		||||
        "debug_toolbar.panels.headers.HeadersPanel",
 | 
			
		||||
        "debug_toolbar.panels.request.RequestPanel",
 | 
			
		||||
        "debug_toolbar.panels.sql.SQLPanel",
 | 
			
		||||
        "debug_toolbar.panels.staticfiles.StaticFilesPanel",
 | 
			
		||||
        "sith.toolbar_debug.TemplatesPanel",
 | 
			
		||||
        "debug_toolbar.panels.cache.CachePanel",
 | 
			
		||||
        "debug_toolbar.panels.signals.SignalsPanel",
 | 
			
		||||
 
 | 
			
		||||
@@ -42,10 +42,6 @@ urlpatterns = [
 | 
			
		||||
    path("club/", include(("club.urls", "club"), namespace="club")),
 | 
			
		||||
    path("counter/", include(("counter.urls", "counter"), namespace="counter")),
 | 
			
		||||
    path("eboutic/", include(("eboutic.urls", "eboutic"), namespace="eboutic")),
 | 
			
		||||
    path(
 | 
			
		||||
        "launderette/",
 | 
			
		||||
        include(("launderette.urls", "launderette"), namespace="launderette"),
 | 
			
		||||
    ),
 | 
			
		||||
    path("sas/", include(("sas.urls", "sas"), namespace="sas")),
 | 
			
		||||
    path("election/", include(("election.urls", "election"), namespace="election")),
 | 
			
		||||
    path("forum/", include(("forum.urls", "forum"), namespace="forum")),
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user