diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 35637457..c001fc7c 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -27,10 +27,10 @@ test:
- sed -i.bak -E 's;^REDIS_PASSWD = .*$;REDIS_PASSWD = "";' cof/settings/secret.py
# Remove the old test database if it has not been done yet
- psql --username=$POSTGRES_USER --host=$DBHOST -c "DROP DATABASE IF EXISTS test_$POSTGRES_DB"
- - pip install --upgrade -r requirements.txt coverage
+ - pip install --upgrade -r requirements.txt coverage tblib
- python --version
script:
- - coverage run manage.py test
+ - coverage run manage.py test --parallel
after_script:
- coverage report
services:
@@ -52,9 +52,9 @@ linters:
- pip install --upgrade black isort flake8
script:
- black --check .
- - isort --recursive --check-only --diff bda cof gestioncof kfet provisioning shared utils
+ - isort --recursive --check-only --diff bda cof gestioncof kfet petitscours provisioning shared utils
# Print errors only
- - flake8 --exit-zero bda cof gestioncof kfet provisioning shared utils
+ - flake8 --exit-zero bda cof gestioncof kfet petitscours provisioning shared utils
cache:
key: linters
paths:
diff --git a/bda/tests/test_views.py b/bda/tests/test_views.py
index 6bfa3257..7204a320 100644
--- a/bda/tests/test_views.py
+++ b/bda/tests/test_views.py
@@ -60,24 +60,20 @@ class BdATestHelpers:
def check_restricted_access(
self, url, validate_user=user_is_cof, redirect_url=None
):
- def craft_redirect_url(user):
- if redirect_url:
- return redirect_url
+ for (user, client) in self.client_matrix:
+ resp = client.get(url, follow=True)
+ if validate_user(user):
+ self.assertEqual(200, resp.status_code)
+ elif redirect_url:
+ self.assertRedirects(resp, redirect_url)
elif user is None:
# client is not logged in
login_url = "/login"
if url:
login_url += "?{}".format(urlencode({"next": url}, safe="/"))
- return login_url
+ self.assertRedirects(resp, login_url)
else:
- return "/"
-
- for (user, client) in self.client_matrix:
- resp = client.get(url, follow=True)
- if validate_user(user):
- self.assertEqual(200, resp.status_code)
- else:
- self.assertRedirects(resp, craft_redirect_url(user))
+ self.assertEqual(403, resp.status_code)
class TestBdAViews(BdATestHelpers, TestCase):
diff --git a/cof/settings/common.py b/cof/settings/common.py
index 22bb5afa..003cd6ce 100644
--- a/cof/settings/common.py
+++ b/cof/settings/common.py
@@ -68,6 +68,7 @@ INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.admindocs",
"bda",
+ "petitscours",
"captcha",
"django_cas_ng",
"bootstrapform",
diff --git a/cof/urls.py b/cof/urls.py
index 4038466f..19a0581f 100644
--- a/cof/urls.py
+++ b/cof/urls.py
@@ -21,7 +21,6 @@ from gestioncof.urls import (
clubs_patterns,
events_patterns,
export_patterns,
- petitcours_patterns,
surveys_patterns,
)
@@ -35,7 +34,7 @@ urlpatterns = [
# Les exports
url(r"^export/", include(export_patterns)),
# Les petits cours
- url(r"^petitcours/", include(petitcours_patterns)),
+ url(r"^petitcours/", include("petitscours.urls")),
# Les sondages
url(r"^survey/", include(surveys_patterns)),
# Evenements
diff --git a/gestioncof/admin.py b/gestioncof/admin.py
index e89d4271..f0fd2a43 100644
--- a/gestioncof/admin.py
+++ b/gestioncof/admin.py
@@ -20,7 +20,7 @@ from gestioncof.models import (
SurveyQuestion,
SurveyQuestionAnswer,
)
-from gestioncof.petits_cours_models import (
+from petitscours.models import (
PetitCoursAbility,
PetitCoursAttribution,
PetitCoursAttributionCounter,
diff --git a/gestioncof/decorators.py b/gestioncof/decorators.py
index ef811730..37d93c7f 100644
--- a/gestioncof/decorators.py
+++ b/gestioncof/decorators.py
@@ -1,23 +1,55 @@
-from django.contrib.auth.decorators import user_passes_test
+from functools import wraps
+
+from django.contrib.auth.decorators import login_required, user_passes_test
+from django.core.exceptions import PermissionDenied
+from django.shortcuts import render
-def is_cof(user):
- try:
- profile = user.profile
- return profile.is_cof
- except Exception:
- return False
+def cof_required(view_func):
+ """Décorateur qui vérifie que l'utilisateur est connecté et membre du COF.
+
+ - Si l'utilisteur n'est pas connecté, il est redirigé vers la page de
+ connexion
+ - Si l'utilisateur est connecté mais pas membre du COF, il obtient une
+ page d'erreur lui demandant de s'inscrire au COF
+ """
+
+ def is_cof(user):
+ try:
+ return user.profile.is_cof
+ except AttributeError:
+ return False
+
+ @wraps(view_func)
+ def _wrapped_view(request, *args, **kwargs):
+ if is_cof(request.user):
+ return view_func(request, *args, **kwargs)
+
+ return render(request, "cof-denied.html", status=403)
+
+ return login_required(_wrapped_view)
-cof_required = user_passes_test(is_cof)
+def buro_required(view_func):
+ """Décorateur qui vérifie que l'utilisateur est connecté et membre du burô.
+ - Si l'utilisateur n'est pas connecté, il est redirigé vers la page de
+ connexion
+ - Si l'utilisateur est connecté mais pas membre du burô, il obtient une
+ page d'erreur 403 Forbidden
+ """
-def is_buro(user):
- try:
- profile = user.profile
- return profile.is_buro
- except Exception:
- return False
+ def is_buro(user):
+ try:
+ return user.profile.is_buro
+ except AttributeError:
+ return False
+ @wraps(view_func)
+ def _wrapped_view(request, *args, **kwargs):
+ if is_buro(request.user):
+ return view_func(request, *args, **kwargs)
-buro_required = user_passes_test(is_buro)
+ return render(request, "buro-denied.html", status=403)
+
+ return login_required(_wrapped_view)
diff --git a/gestioncof/management/commands/loaddevdata.py b/gestioncof/management/commands/loaddevdata.py
index 44d77065..05336050 100644
--- a/gestioncof/management/commands/loaddevdata.py
+++ b/gestioncof/management/commands/loaddevdata.py
@@ -14,7 +14,7 @@ from django.contrib.auth.models import User
from django.core.management import call_command
from gestioncof.management.base import MyBaseCommand
-from gestioncof.petits_cours_models import (
+from petitscours.models import (
LEVELS_CHOICES,
PetitCoursAbility,
PetitCoursAttributionCounter,
diff --git a/gestioncof/models.py b/gestioncof/models.py
index 227fa936..98b947a1 100644
--- a/gestioncof/models.py
+++ b/gestioncof/models.py
@@ -5,7 +5,7 @@ from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _
from bda.models import Spectacle
-from gestioncof.petits_cours_models import choices_length
+from petitscours.models import choices_length
TYPE_COMMENT_FIELD = (("text", _("Texte long")), ("char", _("Texte court")))
diff --git a/gestioncof/signals.py b/gestioncof/signals.py
index 3614b1c8..cf4b1f16 100644
--- a/gestioncof/signals.py
+++ b/gestioncof/signals.py
@@ -15,7 +15,7 @@ def messages_on_out_login(request, user, **kwargs):
@receiver(cas_user_authenticated)
-def mesagges_on_cas_login(request, user, **kwargs):
+def messages_on_cas_login(request, user, **kwargs):
msg = _("Connexion à GestioCOF par CAS réussie. Bienvenue {}.").format(
user.get_short_name()
)
diff --git a/gestioncof/templates/buro-denied.html b/gestioncof/templates/buro-denied.html
new file mode 100644
index 00000000..1e477751
--- /dev/null
+++ b/gestioncof/templates/buro-denied.html
@@ -0,0 +1,5 @@
+{% extends "base_title.html" %}
+
+{% block realcontent %}
+
Section réservée au Burô.
+{% endblock %}
diff --git a/gestioncof/tests/test_views.py b/gestioncof/tests/test_views.py
index 43ea148c..e945a87a 100644
--- a/gestioncof/tests/test_views.py
+++ b/gestioncof/tests/test_views.py
@@ -76,7 +76,7 @@ class RegistrationViewTests(ViewTestCaseMixin, TestCase):
"last_name": "last",
"email": "username@mail.net",
"is_cof": "1",
- }
+ },
),
)
@@ -111,7 +111,7 @@ class RegistrationViewTests(ViewTestCaseMixin, TestCase):
"email": "user@mail.net",
"is_cof": "1",
"user_exists": "1",
- }
+ },
),
)
@@ -137,7 +137,7 @@ class RegistrationViewTests(ViewTestCaseMixin, TestCase):
data = dict(
self._minimal_data,
- **{"username": u.username, "email": "user@mail.net", "user_exists": "1"}
+ **{"username": u.username, "email": "user@mail.net", "user_exists": "1"},
)
if is_cof:
data["is_cof"] = "1"
@@ -197,7 +197,7 @@ class RegistrationViewTests(ViewTestCaseMixin, TestCase):
"events-0-option_{}".format(o2.pk): [str(oc3.pk)],
"events-0-comment_{}".format(cf1.pk): "comment 1",
"events-0-comment_{}".format(cf2.pk): "",
- }
+ },
),
)
diff --git a/gestioncof/urls.py b/gestioncof/urls.py
index c4414fa5..e1e36a17 100644
--- a/gestioncof/urls.py
+++ b/gestioncof/urls.py
@@ -1,8 +1,7 @@
from django.conf.urls import url
-from gestioncof import petits_cours_views, views
+from gestioncof import views
from gestioncof.decorators import buro_required
-from gestioncof.petits_cours_views import DemandeDetailView, DemandeListView
export_patterns = [
url(r"^members$", views.export_members, name="cof.membres_export"),
@@ -21,40 +20,6 @@ export_patterns = [
url(r"^mega$", views.export_mega, name="cof.mega_export"),
]
-petitcours_patterns = [
- url(
- r"^inscription$",
- petits_cours_views.inscription,
- name="petits-cours-inscription",
- ),
- url(r"^demande$", petits_cours_views.demande, name="petits-cours-demande"),
- url(
- r"^demande-raw$",
- petits_cours_views.demande_raw,
- name="petits-cours-demande-raw",
- ),
- url(
- r"^demandes$",
- buro_required(DemandeListView.as_view()),
- name="petits-cours-demandes-list",
- ),
- url(
- r"^demandes/(?P\d+)$",
- buro_required(DemandeDetailView.as_view()),
- name="petits-cours-demande-details",
- ),
- url(
- r"^demandes/(?P\d+)/traitement$",
- petits_cours_views.traitement,
- name="petits-cours-demande-traitement",
- ),
- url(
- r"^demandes/(?P\d+)/retraitement$",
- petits_cours_views.retraitement,
- name="petits-cours-demande-retraitement",
- ),
-]
-
surveys_patterns = [
url(
r"^(?P\d+)/status$",
diff --git a/kfet/open/tests.py b/kfet/open/tests.py
index 75a9bf8a..b4481994 100644
--- a/kfet/open/tests.py
+++ b/kfet/open/tests.py
@@ -1,4 +1,5 @@
import json
+import threading
from datetime import timedelta
from unittest import mock
@@ -8,7 +9,7 @@ from django.contrib.auth.models import AnonymousUser, Permission, User
from django.test import Client
from django.utils import timezone
-from . import OpenKfet, kfet_open
+from . import OpenKfet
from .consumers import OpenKfetConsumer
@@ -16,10 +17,10 @@ class OpenKfetTest(ChannelTestCase):
"""OpenKfet object unit-tests suite."""
def setUp(self):
- self.kfet_open = OpenKfet()
-
- def tearDown(self):
- self.kfet_open.clear_cache()
+ self.kfet_open = OpenKfet(
+ cache_prefix="test_kfetopen_%s" % threading.get_ident()
+ )
+ self.addCleanup(self.kfet_open.clear_cache)
def test_defaults(self):
"""Default values."""
@@ -136,8 +137,14 @@ class OpenKfetViewsTest(ChannelTestCase):
self.c_a = Client()
self.c_a.login(username="admin", password="admin")
- def tearDown(self):
- kfet_open.clear_cache()
+ self.kfet_open = OpenKfet(
+ cache_prefix="test_kfetopen_%s" % threading.get_ident()
+ )
+ self.addCleanup(self.kfet_open.clear_cache)
+
+ views_patcher = mock.patch("kfet.open.views.kfet_open", self.kfet_open)
+ views_patcher.start()
+ self.addCleanup(views_patcher.stop)
def test_door(self):
"""Edit raw_status."""
@@ -146,14 +153,14 @@ class OpenKfetViewsTest(ChannelTestCase):
"/k-fet/open/raw_open", {"raw_open": sent, "token": "plop"}
)
self.assertEqual(200, resp.status_code)
- self.assertEqual(expected, kfet_open.raw_open)
+ self.assertEqual(expected, self.kfet_open.raw_open)
def test_force_close(self):
"""Edit force_close."""
for sent, expected in [(1, True), (0, False)]:
resp = self.c_a.post("/k-fet/open/force_close", {"force_close": sent})
self.assertEqual(200, resp.status_code)
- self.assertEqual(expected, kfet_open.force_close)
+ self.assertEqual(expected, self.kfet_open.force_close)
def test_force_close_forbidden(self):
"""Can't edit force_close without kfet.can_force_close permission."""
@@ -236,8 +243,10 @@ class OpenKfetScenarioTest(ChannelTestCase):
self.r_c_ws = WSClient()
self.r_c_ws.force_login(self.r)
- def tearDown(self):
- kfet_open.clear_cache()
+ self.kfet_open = OpenKfet(
+ cache_prefix="test_kfetopen_%s" % threading.get_ident()
+ )
+ self.addCleanup(self.kfet_open.clear_cache)
def ws_connect(self, ws_client):
ws_client.send_and_consume(
@@ -288,8 +297,8 @@ class OpenKfetScenarioTest(ChannelTestCase):
def test_scenario_2(self):
"""Starting falsely closed, clients connect, disable force close."""
- kfet_open.raw_open = True
- kfet_open.force_close = True
+ self.kfet_open.raw_open = True
+ self.kfet_open.force_close = True
msg = self.ws_connect(self.c_ws)
self.assertEqual(OpenKfet.CLOSED, msg["status"])
diff --git a/kfet/tests/test_tests_utils.py b/kfet/tests/test_tests_utils.py
index 45ca2348..25046abb 100644
--- a/kfet/tests/test_tests_utils.py
+++ b/kfet/tests/test_tests_utils.py
@@ -1,3 +1,6 @@
+from decimal import Decimal
+from unittest import mock
+
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
@@ -5,9 +8,16 @@ from django.test import TestCase
from gestioncof.models import CofProfile
-from ..models import Account
+from ..models import Account, Article, ArticleCategory, Checkout, Operation
from .testcases import TestCaseMixin
-from .utils import create_root, create_team, create_user, get_perms, user_add_perms
+from .utils import (
+ create_operation_group,
+ create_root,
+ create_team,
+ create_user,
+ get_perms,
+ user_add_perms,
+)
User = get_user_model()
@@ -86,3 +96,80 @@ class PermHelpersTest(TestCaseMixin, TestCase):
map(repr, [self.perm1, self.perm2, self.perm_team]),
ordered=False,
)
+
+
+class OperationHelpersTest(TestCase):
+ def test_create_operation_group(self):
+ operation_group = create_operation_group()
+
+ on_acc = Account.objects.get(cofprofile__user__username="user")
+ checkout = Checkout.objects.get(name="Checkout")
+ self.assertDictEqual(
+ operation_group.__dict__,
+ {
+ "_checkout_cache": checkout,
+ "_on_acc_cache": on_acc,
+ "_state": mock.ANY,
+ "amount": 0,
+ "at": mock.ANY,
+ "checkout_id": checkout.pk,
+ "comment": "",
+ "id": mock.ANY,
+ "is_cof": False,
+ "on_acc_id": on_acc.pk,
+ "valid_by_id": None,
+ },
+ )
+ self.assertFalse(operation_group.opes.all())
+
+ def test_create_operation_group_with_content(self):
+ article_category = ArticleCategory.objects.create(name="Category")
+ article1 = Article.objects.create(
+ category=article_category, name="Article 1", price=Decimal("2.50")
+ )
+ article2 = Article.objects.create(
+ category=article_category, name="Article 2", price=Decimal("4.00")
+ )
+ operation_group = create_operation_group(
+ content=[
+ {
+ "type": Operation.PURCHASE,
+ "amount": Decimal("-3.50"),
+ "article": article1,
+ "article_nb": 2,
+ },
+ {"type": Operation.PURCHASE, "article": article2, "article_nb": 2},
+ {"type": Operation.PURCHASE, "article": article2},
+ {"type": Operation.DEPOSIT, "amount": Decimal("10.00")},
+ {"type": Operation.WITHDRAW, "amount": Decimal("-1.00")},
+ {"type": Operation.EDIT, "amount": Decimal("7.00")},
+ ]
+ )
+
+ self.assertEqual(operation_group.amount, Decimal("0.50"))
+
+ operation_list = list(operation_group.opes.all())
+ # Passed args: with purchase, article, article_nb, amount
+ self.assertEqual(operation_list[0].type, Operation.PURCHASE)
+ self.assertEqual(operation_list[0].article, article1)
+ self.assertEqual(operation_list[0].article_nb, 2)
+ self.assertEqual(operation_list[0].amount, Decimal("-3.50"))
+ # Passed args: with purchase, article, article_nb; without amount
+ self.assertEqual(operation_list[1].type, Operation.PURCHASE)
+ self.assertEqual(operation_list[1].article, article2)
+ self.assertEqual(operation_list[1].article_nb, 2)
+ self.assertEqual(operation_list[1].amount, Decimal("-8.00"))
+ # Passed args: with purchase, article; without article_nb, amount
+ self.assertEqual(operation_list[2].type, Operation.PURCHASE)
+ self.assertEqual(operation_list[2].article, article2)
+ self.assertEqual(operation_list[2].article_nb, 1)
+ self.assertEqual(operation_list[2].amount, Decimal("-4.00"))
+ # Passed args: with deposit, amount
+ self.assertEqual(operation_list[3].type, Operation.DEPOSIT)
+ self.assertEqual(operation_list[3].amount, Decimal("10.00"))
+ # Passed args: with withdraw, amount
+ self.assertEqual(operation_list[4].type, Operation.WITHDRAW)
+ self.assertEqual(operation_list[4].amount, Decimal("-1.00"))
+ # Passed args: with edit, amount
+ self.assertEqual(operation_list[5].type, Operation.EDIT)
+ self.assertEqual(operation_list[5].amount, Decimal("7.00"))
diff --git a/kfet/tests/test_views.py b/kfet/tests/test_views.py
index bd57b6f8..e5ccfaa1 100644
--- a/kfet/tests/test_views.py
+++ b/kfet/tests/test_views.py
@@ -28,7 +28,16 @@ from ..models import (
TransferGroup,
)
from .testcases import ViewTestCaseMixin
-from .utils import create_team, create_user, get_perms, user_add_perms
+from .utils import (
+ create_checkout,
+ create_checkout_statement,
+ create_inventory_article,
+ create_operation_group,
+ create_team,
+ create_user,
+ get_perms,
+ user_add_perms,
+)
class AccountListViewTests(ViewTestCaseMixin, TestCase):
@@ -2952,6 +2961,21 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase):
class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase):
+ """
+ Test cases for kpsul_cancel_operations view.
+
+ To test valid requests, one should use '_assertResponseOk(response)' to get
+ hints about failure reasons, if any.
+
+ At least one test per operation type should test the complete response and
+ behavior (HTTP, WebSocket, object updates, and object creations)
+ Other tests of the same operation type can only assert the specific
+ behavior differences.
+
+ For invalid requests, response errors should be tested.
+
+ """
+
url_name = "kfet.kpsul.cancel_operations"
url_expected = "/k-fet/k-psul/cancel_operations"
@@ -2960,8 +2984,790 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase):
auth_user = "team"
auth_forbidden = [None, "user"]
- def test_ok(self):
- pass
+ with_liq = True
+
+ def setUp(self):
+ super(KPsulCancelOperationsViewTests, self).setUp()
+
+ self.checkout = create_checkout(balance=Decimal("100.00"))
+ # An Article, price=2.5, stock=20
+ self.article = Article.objects.create(
+ category=ArticleCategory.objects.create(name="Category"),
+ name="Article",
+ price=Decimal("2.5"),
+ stock=20,
+ )
+ # An Account, trigramme=000, balance=50
+ # Do not assume user is cof, nor not cof.
+ self.account = self.accounts["user"]
+ self.account.balance = Decimal("50.00")
+ self.account.save()
+
+ # Mock consumer of K-Psul websocket to catch what we're sending
+ kpsul_consumer_patcher = mock.patch("kfet.consumers.KPsul")
+ self.kpsul_consumer_mock = kpsul_consumer_patcher.start()
+ self.addCleanup(kpsul_consumer_patcher.stop)
+
+ def _assertResponseOk(self, response):
+ """
+ Asserts that status code of 'response' is 200, and returns the
+ deserialized content of the JSONResponse.
+
+ In case status code is not 200, it prints the content of "errors" of
+ the response.
+
+ """
+ json_data = json.loads(getattr(response, "content", b"{}").decode("utf-8"))
+ try:
+ self.assertEqual(response.status_code, 200)
+ except AssertionError as exc:
+ msg = "Expected response is 200, got {}. Errors: {}".format(
+ response.status_code, json_data.get("errors")
+ )
+ raise AssertionError(msg) from exc
+ return json_data
+
+ def test_invalid_operation_not_int(self):
+ data = {"operations[]": ["a"]}
+ resp = self.client.post(self.url, data)
+
+ self.assertEqual(resp.status_code, 400)
+ json_data = json.loads(resp.content.decode("utf-8"))
+ self.assertEqual(json_data["errors"], {})
+
+ def test_invalid_operation_not_exist(self):
+ data = {"operations[]": ["1000"]}
+ resp = self.client.post(self.url, data)
+
+ self.assertEqual(resp.status_code, 400)
+ json_data = json.loads(resp.content.decode("utf-8"))
+ self.assertEqual(json_data["errors"], {"opes_notexisting": [1000]})
+
+ @mock.patch("django.utils.timezone.now")
+ def test_purchase(self, now_mock):
+ now_mock.return_value = self.now
+ group = create_operation_group(
+ on_acc=self.account,
+ checkout=self.checkout,
+ content=[
+ {"type": Operation.PURCHASE, "article": self.article, "article_nb": 2}
+ ],
+ )
+ operation = group.opes.get()
+ now_mock.return_value += timedelta(seconds=15)
+
+ data = {"operations[]": [str(operation.pk)]}
+ resp = self.client.post(self.url, data)
+
+ json_data = self._assertResponseOk(resp)
+
+ group = OperationGroup.objects.get()
+ self.assertDictEqual(
+ group.__dict__,
+ {
+ "_state": mock.ANY,
+ "amount": Decimal("0.00"),
+ "at": mock.ANY,
+ "checkout_id": self.checkout.pk,
+ "comment": "",
+ "id": mock.ANY,
+ "is_cof": False,
+ "on_acc_id": self.account.pk,
+ "valid_by_id": None,
+ },
+ )
+ operation = Operation.objects.get()
+ self.assertDictEqual(
+ operation.__dict__,
+ {
+ "_state": mock.ANY,
+ "addcost_amount": None,
+ "addcost_for_id": None,
+ "amount": Decimal("-5.00"),
+ "article_id": self.article.pk,
+ "article_nb": 2,
+ "canceled_at": self.now + timedelta(seconds=15),
+ "canceled_by_id": None,
+ "group_id": group.pk,
+ "id": mock.ANY,
+ "type": Operation.PURCHASE,
+ },
+ )
+
+ self.assertDictEqual(
+ json_data, {"canceled": [operation.pk], "errors": {}, "warnings": {}}
+ )
+
+ self.account.refresh_from_db()
+ self.assertEqual(self.account.balance, Decimal("55.00"))
+ self.article.refresh_from_db()
+ self.assertEqual(self.article.stock, 22)
+ self.checkout.refresh_from_db()
+ self.assertEqual(self.checkout.balance, Decimal("100.00"))
+
+ self.kpsul_consumer_mock.group_send.assert_called_with(
+ "kfet.kpsul",
+ {
+ "opegroups": [
+ {
+ "cancellation": True,
+ "id": group.pk,
+ "amount": Decimal("0.00"),
+ "is_cof": False,
+ }
+ ],
+ "opes": [
+ {
+ "cancellation": True,
+ "id": operation.pk,
+ "canceled_by__trigramme": None,
+ "canceled_at": self.now + timedelta(seconds=15),
+ }
+ ],
+ "checkouts": [],
+ "articles": [{"id": self.article.pk, "stock": 22}],
+ },
+ )
+
+ def test_purchase_with_addcost(self):
+ # TODO(AD): L'état de la balance du compte destinataire de la majoration ne
+ # devrait pas empêcher l'annulation d'une opération.
+ addcost_user = create_user(
+ "addcost", "ADD", account_attrs={"balance": Decimal("10.00")}
+ )
+ addcost_account = addcost_user.profile.account_kfet
+ group = create_operation_group(
+ on_acc=self.account,
+ checkout=self.checkout,
+ content=[
+ {
+ "type": Operation.PURCHASE,
+ "article": self.article,
+ "article_nb": 2,
+ "amount": Decimal("-6.00"),
+ "addcost_amount": Decimal("1.00"),
+ "addcost_for": addcost_account,
+ }
+ ],
+ )
+ operation = group.opes.get()
+
+ data = {"operations[]": [str(operation.pk)]}
+ resp = self.client.post(self.url, data)
+
+ self._assertResponseOk(resp)
+
+ self.account.refresh_from_db()
+ self.assertEqual(self.account.balance, Decimal("56.00"))
+ addcost_account.refresh_from_db()
+ self.assertEqual(addcost_account.balance, Decimal("9.00"))
+
+ def test_purchase_cash(self):
+ group = create_operation_group(
+ on_acc=self.accounts["liq"],
+ checkout=self.checkout,
+ content=[
+ {
+ "type": Operation.PURCHASE,
+ "article": self.article,
+ "article_nb": 2,
+ "amount": Decimal("-5.00"),
+ }
+ ],
+ )
+ operation = group.opes.get()
+
+ data = {"operations[]": [str(operation.pk)]}
+ resp = self.client.post(self.url, data)
+
+ self._assertResponseOk(resp)
+
+ self.assertEqual(self.accounts["liq"].balance, Decimal("0.00"))
+ self.checkout.refresh_from_db()
+ self.assertEqual(self.checkout.balance, Decimal("95.00"))
+
+ ws_data_checkouts = self.kpsul_consumer_mock.group_send.call_args[0][1][
+ "checkouts"
+ ]
+ self.assertListEqual(
+ ws_data_checkouts, [{"id": self.checkout.pk, "balance": Decimal("95.00")}]
+ )
+
+ def test_purchase_cash_with_addcost(self):
+ # TODO(AD): L'état de la balance du compte destinataire de la majoration ne
+ # devrait pas empêcher l'annulation d'une opération.
+ addcost_user = create_user(
+ "addcost", "ADD", account_attrs={"balance": Decimal("10.00")}
+ )
+ addcost_account = addcost_user.profile.account_kfet
+ group = create_operation_group(
+ on_acc=self.accounts["liq"],
+ checkout=self.checkout,
+ content=[
+ {
+ "type": Operation.PURCHASE,
+ "article": self.article,
+ "article_nb": 2,
+ "amount": Decimal("-6.00"),
+ "addcost_amount": Decimal("1.00"),
+ "addcost_for": addcost_account,
+ }
+ ],
+ )
+ operation = group.opes.get()
+
+ data = {"operations[]": [str(operation.pk)]}
+ resp = self.client.post(self.url, data)
+
+ self._assertResponseOk(resp)
+
+ self.checkout.refresh_from_db()
+ self.assertEqual(self.checkout.balance, Decimal("94.00"))
+ addcost_account.refresh_from_db()
+ self.assertEqual(addcost_account.balance, Decimal("9.00"))
+
+ ws_data_checkouts = self.kpsul_consumer_mock.group_send.call_args[0][1][
+ "checkouts"
+ ]
+ self.assertListEqual(
+ ws_data_checkouts, [{"id": self.checkout.pk, "balance": Decimal("94.00")}]
+ )
+
+ @mock.patch("django.utils.timezone.now")
+ def test_deposit(self, now_mock):
+ now_mock.return_value = self.now
+ group = create_operation_group(
+ on_acc=self.account,
+ checkout=self.checkout,
+ content=[{"type": Operation.DEPOSIT, "amount": Decimal("10.75")}],
+ )
+ operation = group.opes.get()
+ now_mock.return_value += timedelta(seconds=15)
+
+ data = {"operations[]": [str(operation.pk)]}
+ resp = self.client.post(self.url, data)
+
+ json_data = self._assertResponseOk(resp)
+
+ group = OperationGroup.objects.get()
+ self.assertDictEqual(
+ group.__dict__,
+ {
+ "_state": mock.ANY,
+ "amount": Decimal("0.00"),
+ "at": mock.ANY,
+ "checkout_id": self.checkout.pk,
+ "comment": "",
+ "id": mock.ANY,
+ "is_cof": False,
+ "on_acc_id": self.account.pk,
+ "valid_by_id": None,
+ },
+ )
+ operation = Operation.objects.get()
+ self.assertDictEqual(
+ operation.__dict__,
+ {
+ "_state": mock.ANY,
+ "addcost_amount": None,
+ "addcost_for_id": None,
+ "amount": Decimal("10.75"),
+ "article_id": None,
+ "article_nb": None,
+ "canceled_at": self.now + timedelta(seconds=15),
+ "canceled_by_id": None,
+ "group_id": group.pk,
+ "id": mock.ANY,
+ "type": Operation.DEPOSIT,
+ },
+ )
+
+ self.assertDictEqual(
+ json_data, {"canceled": [operation.pk], "errors": {}, "warnings": {}}
+ )
+
+ self.account.refresh_from_db()
+ self.assertEqual(self.account.balance, Decimal("39.25"))
+ self.article.refresh_from_db()
+ self.assertEqual(self.article.stock, 20)
+ self.checkout.refresh_from_db()
+ self.assertEqual(self.checkout.balance, Decimal("89.25"))
+
+ self.kpsul_consumer_mock.group_send.assert_called_with(
+ "kfet.kpsul",
+ {
+ "opegroups": [
+ {
+ "cancellation": True,
+ "id": group.pk,
+ "amount": Decimal("0.00"),
+ "is_cof": False,
+ }
+ ],
+ "opes": [
+ {
+ "cancellation": True,
+ "id": operation.pk,
+ "canceled_by__trigramme": None,
+ "canceled_at": self.now + timedelta(seconds=15),
+ }
+ ],
+ "checkouts": [{"id": self.checkout.pk, "balance": Decimal("89.25")}],
+ "articles": [],
+ },
+ )
+
+ @mock.patch("django.utils.timezone.now")
+ def test_withdraw(self, now_mock):
+ now_mock.return_value = self.now
+ group = create_operation_group(
+ on_acc=self.account,
+ checkout=self.checkout,
+ content=[{"type": Operation.WITHDRAW, "amount": Decimal("-10.75")}],
+ )
+ operation = group.opes.get()
+ now_mock.return_value += timedelta(seconds=15)
+
+ data = {"operations[]": [str(operation.pk)]}
+ resp = self.client.post(self.url, data)
+
+ json_data = self._assertResponseOk(resp)
+
+ group = OperationGroup.objects.get()
+ self.assertDictEqual(
+ group.__dict__,
+ {
+ "_state": mock.ANY,
+ "amount": Decimal("0.00"),
+ "at": mock.ANY,
+ "checkout_id": self.checkout.pk,
+ "comment": "",
+ "id": mock.ANY,
+ "is_cof": False,
+ "on_acc_id": self.account.pk,
+ "valid_by_id": None,
+ },
+ )
+ operation = Operation.objects.get()
+ self.assertDictEqual(
+ operation.__dict__,
+ {
+ "_state": mock.ANY,
+ "addcost_amount": None,
+ "addcost_for_id": None,
+ "amount": Decimal("-10.75"),
+ "article_id": None,
+ "article_nb": None,
+ "canceled_at": self.now + timedelta(seconds=15),
+ "canceled_by_id": None,
+ "group_id": group.pk,
+ "id": mock.ANY,
+ "type": Operation.WITHDRAW,
+ },
+ )
+
+ self.assertDictEqual(
+ json_data, {"canceled": [operation.pk], "errors": {}, "warnings": {}}
+ )
+
+ self.account.refresh_from_db()
+ self.assertEqual(self.account.balance, Decimal("60.75"))
+ self.article.refresh_from_db()
+ self.assertEqual(self.article.stock, 20)
+ self.checkout.refresh_from_db()
+ self.assertEqual(self.checkout.balance, Decimal("110.75"))
+
+ self.kpsul_consumer_mock.group_send.assert_called_with(
+ "kfet.kpsul",
+ {
+ "opegroups": [
+ {
+ "cancellation": True,
+ "id": group.pk,
+ "amount": Decimal("0.00"),
+ "is_cof": False,
+ }
+ ],
+ "opes": [
+ {
+ "cancellation": True,
+ "id": operation.pk,
+ "canceled_by__trigramme": None,
+ "canceled_at": self.now + timedelta(seconds=15),
+ }
+ ],
+ "checkouts": [{"id": self.checkout.pk, "balance": Decimal("110.75")}],
+ "articles": [],
+ },
+ )
+
+ @mock.patch("django.utils.timezone.now")
+ def test_edit(self, now_mock):
+ now_mock.return_value = self.now
+ group = create_operation_group(
+ on_acc=self.account,
+ checkout=self.checkout,
+ content=[{"type": Operation.EDIT, "amount": Decimal("-10.75")}],
+ )
+ operation = group.opes.get()
+ now_mock.return_value += timedelta(seconds=15)
+
+ data = {"operations[]": [str(operation.pk)]}
+ resp = self.client.post(self.url, data)
+
+ json_data = self._assertResponseOk(resp)
+
+ group = OperationGroup.objects.get()
+ self.assertDictEqual(
+ group.__dict__,
+ {
+ "_state": mock.ANY,
+ "amount": Decimal("0.00"),
+ "at": mock.ANY,
+ "checkout_id": self.checkout.pk,
+ "comment": "",
+ "id": mock.ANY,
+ "is_cof": False,
+ "on_acc_id": self.account.pk,
+ "valid_by_id": None,
+ },
+ )
+ operation = Operation.objects.get()
+ self.assertDictEqual(
+ operation.__dict__,
+ {
+ "_state": mock.ANY,
+ "addcost_amount": None,
+ "addcost_for_id": None,
+ "amount": Decimal("-10.75"),
+ "article_id": None,
+ "article_nb": None,
+ "canceled_at": self.now + timedelta(seconds=15),
+ "canceled_by_id": None,
+ "group_id": group.pk,
+ "id": mock.ANY,
+ "type": Operation.EDIT,
+ },
+ )
+
+ self.assertDictEqual(
+ json_data, {"canceled": [operation.pk], "errors": {}, "warnings": {}}
+ )
+
+ self.account.refresh_from_db()
+ self.assertEqual(self.account.balance, Decimal("60.75"))
+ self.article.refresh_from_db()
+ self.assertEqual(self.article.stock, 20)
+ self.checkout.refresh_from_db()
+ self.assertEqual(self.checkout.balance, Decimal("100.00"))
+
+ self.kpsul_consumer_mock.group_send.assert_called_with(
+ "kfet.kpsul",
+ {
+ "opegroups": [
+ {
+ "cancellation": True,
+ "id": group.pk,
+ "amount": Decimal("0.00"),
+ "is_cof": False,
+ }
+ ],
+ "opes": [
+ {
+ "cancellation": True,
+ "id": operation.pk,
+ "canceled_by__trigramme": None,
+ "canceled_at": self.now + timedelta(seconds=15),
+ }
+ ],
+ "checkouts": [],
+ "articles": [],
+ },
+ )
+
+ @mock.patch("django.utils.timezone.now")
+ def test_old_operations(self, now_mock):
+ kfet_config.set(cancel_duration=timedelta(minutes=10))
+ user_add_perms(self.users["team"], ["kfet.cancel_old_operations"])
+ now_mock.return_value = self.now
+ group = create_operation_group(
+ at=self.now,
+ on_acc=self.account,
+ checkout=self.checkout,
+ content=[{"type": Operation.WITHDRAW, "amount": Decimal("-10.75")}],
+ )
+ operation = group.opes.get()
+ now_mock.return_value += timedelta(minutes=10, seconds=1)
+
+ data = {"operations[]": [str(operation.pk)]}
+ resp = self.client.post(self.url, data)
+
+ json_data = self._assertResponseOk(resp)
+ self.assertEqual(len(json_data["canceled"]), 1)
+
+ @mock.patch("django.utils.timezone.now")
+ def test_invalid_old_operations_requires_perm(self, now_mock):
+ kfet_config.set(cancel_duration=timedelta(minutes=10))
+ now_mock.return_value = self.now
+ group = create_operation_group(
+ at=self.now,
+ on_acc=self.account,
+ checkout=self.checkout,
+ content=[{"type": Operation.WITHDRAW, "amount": Decimal("-10.75")}],
+ )
+ operation = group.opes.get()
+ now_mock.return_value += timedelta(minutes=10, seconds=1)
+
+ data = {"operations[]": [str(operation.pk)]}
+ resp = self.client.post(self.url, data)
+
+ self.assertEqual(resp.status_code, 403)
+ json_data = json.loads(resp.content.decode("utf-8"))
+ self.assertEqual(
+ json_data["errors"],
+ {"missing_perms": ["Annuler des commandes non récentes"]},
+ )
+
+ def test_already_canceled(self):
+ group = create_operation_group(
+ on_acc=self.account,
+ checkout=self.checkout,
+ content=[
+ {
+ "type": Operation.WITHDRAW,
+ "amount": Decimal("-10.75"),
+ "canceled_at": timezone.now(),
+ }
+ ],
+ )
+ operation = group.opes.get()
+
+ data = {"operations[]": [str(operation.pk)]}
+ resp = self.client.post(self.url, data)
+
+ json_data = self._assertResponseOk(resp)
+
+ self.assertDictEqual(
+ json_data["warnings"], {"already_canceled": [operation.pk]}
+ )
+
+ self.account.refresh_from_db()
+ self.assertEqual(self.account.balance, Decimal("50.00"))
+ self.checkout.refresh_from_db()
+ self.assertEqual(self.checkout.balance, Decimal("100.00"))
+
+ @mock.patch("django.utils.timezone.now")
+ def test_checkout_before_last_statement(self, now_mock):
+ now_mock.return_value = self.now
+ group = create_operation_group(
+ at=self.now,
+ on_acc=self.account,
+ checkout=self.checkout,
+ content=[{"type": Operation.WITHDRAW, "amount": Decimal("-10.75")}],
+ )
+ operation = group.opes.get()
+ now_mock.return_value += timedelta(seconds=30)
+ create_checkout_statement(checkout=self.checkout)
+ now_mock.return_value += timedelta(seconds=30)
+
+ data = {"operations[]": [str(operation.pk)]}
+ resp = self.client.post(self.url, data)
+
+ json_data = self._assertResponseOk(resp)
+
+ self.assertEqual(len(json_data["canceled"]), 1)
+
+ self.account.refresh_from_db()
+ self.assertEqual(self.account.balance, Decimal("60.75"))
+ self.checkout.refresh_from_db()
+ self.assertEqual(self.checkout.balance, Decimal("100.00"))
+
+ @mock.patch("django.utils.timezone.now")
+ def test_article_before_last_inventory(self, now_mock):
+ now_mock.return_value = self.now
+ group = create_operation_group(
+ at=self.now,
+ on_acc=self.account,
+ checkout=self.checkout,
+ content=[
+ {"type": Operation.PURCHASE, "article": self.article, "article_nb": 2}
+ ],
+ )
+ operation = group.opes.get()
+ now_mock.return_value += timedelta(seconds=30)
+ create_inventory_article(article=self.article)
+ now_mock.return_value += timedelta(seconds=30)
+
+ data = {"operations[]": [str(operation.pk)]}
+ resp = self.client.post(self.url, data)
+
+ json_data = self._assertResponseOk(resp)
+
+ self.assertEqual(len(json_data["canceled"]), 1)
+
+ self.account.refresh_from_db()
+ self.assertEqual(self.account.balance, Decimal("55.00"))
+ self.article.refresh_from_db()
+ self.assertEqual(self.article.stock, 20)
+
+ def test_negative(self):
+ kfet_config.set(overdraft_amount=Decimal("40.00"))
+ user_add_perms(self.users["team"], ["kfet.perform_negative_operations"])
+ self.account.balance = Decimal("-20.00")
+ self.account.save()
+ group = create_operation_group(
+ on_acc=self.account,
+ checkout=self.checkout,
+ content=[{"type": Operation.DEPOSIT, "amount": Decimal("10.75")}],
+ )
+ operation = group.opes.get()
+
+ data = {"operations[]": [str(operation.pk)]}
+ resp = self.client.post(self.url, data)
+
+ json_data = self._assertResponseOk(resp)
+
+ self.assertEqual(len(json_data["canceled"]), 1)
+
+ self.account.refresh_from_db()
+ self.assertEqual(self.account.balance, Decimal("-30.75"))
+ self.checkout.refresh_from_db()
+ self.assertEqual(self.checkout.balance, Decimal("89.25"))
+
+ def test_invalid_negative_above_thresholds(self):
+ kfet_config.set(overdraft_amount=Decimal("5.00"))
+ user_add_perms(self.users["team"], ["kfet.perform_negative_operations"])
+ self.account.balance = Decimal("-20.00")
+ self.account.save()
+ group = create_operation_group(
+ on_acc=self.account,
+ checkout=self.checkout,
+ content=[{"type": Operation.DEPOSIT, "amount": Decimal("10.75")}],
+ )
+ operation = group.opes.get()
+
+ data = {"operations[]": [str(operation.pk)]}
+ resp = self.client.post(self.url, data)
+
+ self.assertEqual(resp.status_code, 403)
+ json_data = json.loads(resp.content.decode("utf-8"))
+ self.assertEqual(json_data["errors"], {"negative": [self.account.trigramme]})
+
+ def test_invalid_negative_requires_perms(self):
+ kfet_config.set(overdraft_amount=Decimal("40.00"))
+ self.account.balance = Decimal("-20.00")
+ self.account.save()
+ group = create_operation_group(
+ on_acc=self.account,
+ checkout=self.checkout,
+ content=[{"type": Operation.DEPOSIT, "amount": Decimal("10.75")}],
+ )
+ operation = group.opes.get()
+
+ data = {"operations[]": [str(operation.pk)]}
+ resp = self.client.post(self.url, data)
+
+ self.assertEqual(resp.status_code, 403)
+ json_data = json.loads(resp.content.decode("utf-8"))
+ self.assertEqual(
+ json_data["errors"],
+ {"missing_perms": ["Enregistrer des commandes en négatif"]},
+ )
+
+ def test_partial_0(self):
+ group = create_operation_group(
+ on_acc=self.account,
+ checkout=self.checkout,
+ content=[
+ {"type": Operation.PURCHASE, "article": self.article, "article_nb": 2},
+ {"type": Operation.DEPOSIT, "amount": Decimal("10.75")},
+ {"type": Operation.EDIT, "amount": Decimal("-6.00")},
+ {
+ "type": Operation.WITHDRAW,
+ "amount": Decimal("-10.75"),
+ "canceled_at": timezone.now(),
+ },
+ ],
+ )
+ operation1 = group.opes.get(type=Operation.PURCHASE)
+ operation2 = group.opes.get(type=Operation.EDIT)
+ operation3 = group.opes.get(type=Operation.WITHDRAW)
+
+ data = {
+ "operations[]": [str(operation1.pk), str(operation2.pk), str(operation3.pk)]
+ }
+ resp = self.client.post(self.url, data)
+
+ json_data = self._assertResponseOk(resp)
+
+ group.refresh_from_db()
+ self.assertEqual(group.amount, Decimal("10.75"))
+ self.assertEqual(group.opes.exclude(canceled_at=None).count(), 3)
+
+ self.assertDictEqual(
+ json_data,
+ {
+ "canceled": [operation1.pk, operation2.pk],
+ "warnings": {"already_canceled": [operation3.pk]},
+ "errors": {},
+ },
+ )
+
+ self.account.refresh_from_db()
+ self.assertEqual(self.account.balance, Decimal("61.00"))
+ self.article.refresh_from_db()
+ self.assertEqual(self.article.stock, 22)
+ self.checkout.refresh_from_db()
+ self.assertEqual(self.checkout.balance, Decimal("100.00"))
+
+ def test_multi_0(self):
+ group1 = create_operation_group(
+ on_acc=self.account,
+ checkout=self.checkout,
+ content=[
+ {"type": Operation.PURCHASE, "article": self.article, "article_nb": 2},
+ {"type": Operation.DEPOSIT, "amount": Decimal("10.75")},
+ {"type": Operation.EDIT, "amount": Decimal("-6.00")},
+ ],
+ )
+ operation11 = group1.opes.get(type=Operation.PURCHASE)
+ group2 = create_operation_group(
+ on_acc=self.account,
+ checkout=self.checkout,
+ content=[
+ {"type": Operation.PURCHASE, "article": self.article, "article_nb": 5},
+ {"type": Operation.DEPOSIT, "amount": Decimal("3.00")},
+ ],
+ )
+ operation21 = group2.opes.get(type=Operation.PURCHASE)
+ operation22 = group2.opes.get(type=Operation.DEPOSIT)
+
+ data = {
+ "operations[]": [
+ str(operation11.pk),
+ str(operation21.pk),
+ str(operation22.pk),
+ ]
+ }
+ resp = self.client.post(self.url, data)
+
+ json_data = self._assertResponseOk(resp)
+
+ group1.refresh_from_db()
+ self.assertEqual(group1.amount, Decimal("4.75"))
+ self.assertEqual(group1.opes.exclude(canceled_at=None).count(), 1)
+ group2.refresh_from_db()
+ self.assertEqual(group2.amount, Decimal(0))
+ self.assertEqual(group2.opes.exclude(canceled_at=None).count(), 2)
+
+ self.assertEqual(len(json_data["canceled"]), 3)
+
+ self.account.refresh_from_db()
+ self.assertEqual(self.account.balance, Decimal("64.50"))
+ self.article.refresh_from_db()
+ self.assertEqual(self.article.stock, 27)
+ self.checkout.refresh_from_db()
+ self.assertEqual(self.checkout.balance, Decimal("97.00"))
class KPsulArticlesData(ViewTestCaseMixin, TestCase):
diff --git a/kfet/tests/utils.py b/kfet/tests/utils.py
index f1b6933a..79ca1b5e 100644
--- a/kfet/tests/utils.py
+++ b/kfet/tests/utils.py
@@ -1,7 +1,21 @@
+from datetime import timedelta
+from decimal import Decimal
+
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
+from django.utils import timezone
-from ..models import Account
+from ..models import (
+ Account,
+ Article,
+ ArticleCategory,
+ Checkout,
+ CheckoutStatement,
+ Inventory,
+ InventoryArticle,
+ Operation,
+ OperationGroup,
+)
User = get_user_model()
@@ -184,3 +198,180 @@ def user_add_perms(user, perms_labels):
# it to avoid using of the previous permissions cache.
# https://docs.djangoproject.com/en/dev/topics/auth/default/#permission-caching
return User.objects.get(pk=user.pk)
+
+
+def create_checkout(**kwargs):
+ """
+ Factory to create a checkout.
+ See defaults for unpassed arguments in code below.
+ """
+ if "created_by" not in kwargs or "created_by_id" not in kwargs:
+ try:
+ team_account = Account.objects.get(cofprofile__user__username="team")
+ except Account.DoesNotExist:
+ team_account = create_team().profile.account_kfet
+ kwargs["created_by"] = team_account
+ kwargs.setdefault("name", "Checkout")
+ kwargs.setdefault("valid_from", timezone.now() - timedelta(days=14))
+ kwargs.setdefault("valid_to", timezone.now() - timedelta(days=14))
+
+ return Checkout.objects.create(**kwargs)
+
+
+def create_operation_group(content=None, **kwargs):
+ """
+ Factory to create an OperationGroup and a set of related Operation.
+
+ It aims to get objects for testing purposes with minimal setup, and
+ preserving consistency.
+ For this, it uses, and creates if necessary, default objects for unpassed
+ arguments.
+
+ Args:
+ content: list of dict
+ Describe set of Operation to create along the OperationGroup.
+ Each item is passed to the Operation factory.
+ kwargs:
+ Used to control OperationGroup creation.
+
+ """
+ if content is None:
+ content = []
+
+ # Prepare OperationGroup creation.
+
+ # Set 'checkout' for OperationGroup if unpassed.
+ if "checkout" not in kwargs and "checkout_id" not in kwargs:
+ try:
+ checkout = Checkout.objects.get(name="Checkout")
+ except Checkout.DoesNotExist:
+ checkout = create_checkout()
+ kwargs["checkout"] = checkout
+
+ # Set 'on_acc' for OperationGroup if unpassed.
+ if "on_acc" not in kwargs and "on_acc_id" not in kwargs:
+ try:
+ on_acc = Account.objects.get(cofprofile__user__username="user")
+ except Account.DoesNotExist:
+ on_acc = create_user().profile.account_kfet
+ kwargs["on_acc"] = on_acc
+
+ # Set 'is_cof' for OperationGroup if unpassed.
+ if "is_cof" not in kwargs:
+ # Use current is_cof status of 'on_acc'.
+ kwargs["is_cof"] = kwargs["on_acc"].cofprofile.is_cof
+
+ # Create OperationGroup.
+ group = OperationGroup.objects.create(**kwargs)
+
+ # We can now create objects referencing this OperationGroup.
+
+ # Process set of related Operation.
+ if content:
+ # Create them.
+ operation_list = []
+ for operation_kwargs in content:
+ operation = create_operation(group=group, **operation_kwargs)
+ operation_list.append(operation)
+
+ # Update OperationGroup accordingly, for consistency.
+ for operation in operation_list:
+ if not operation.canceled_at:
+ group.amount += operation.amount
+ group.save()
+
+ return group
+
+
+def create_operation(**kwargs):
+ """
+ Factory to create an Operation for testing purposes.
+
+ If you give a 'group' (OperationGroup), it won't update it, you have do
+ this "manually". Prefer using OperationGroup factory to get a consistent
+ group with operations.
+
+ """
+ if "group" not in kwargs and "group_id" not in kwargs:
+ # To get a consistent OperationGroup (amount...) for the operation
+ # in-creation, prefer using create_operation_group factory with
+ # 'content'.
+ kwargs["group"] = create_operation_group()
+
+ if "type" not in kwargs:
+ raise RuntimeError("Can't create an Operation without 'type'.")
+
+ # Apply defaults for purchase
+ if kwargs["type"] == Operation.PURCHASE:
+ if "article" not in kwargs:
+ raise NotImplementedError(
+ "One could write a create_article factory. Right now, you must"
+ "pass an 'article'."
+ )
+
+ # Unpassed 'article_nb' defaults to 1.
+ kwargs.setdefault("article_nb", 1)
+
+ # Unpassed 'amount' will use current article price and quantity.
+ if "amount" not in kwargs:
+ if "addcost_for" in kwargs or "addcost_amount" in kwargs:
+ raise NotImplementedError(
+ "One could handle the case where 'amount' is missing and "
+ "addcost applies. Right now, please pass an 'amount'."
+ )
+ kwargs["amount"] = -kwargs["article"].price * kwargs["article_nb"]
+
+ return Operation.objects.create(**kwargs)
+
+
+def create_checkout_statement(**kwargs):
+ if "checkout" not in kwargs:
+ kwargs["checkout"] = create_checkout()
+ if "by" not in kwargs:
+ try:
+ team_account = Account.objects.get(cofprofile__user__username="team")
+ except Account.DoesNotExist:
+ team_account = create_team().profile.account_kfet
+ kwargs["by"] = team_account
+ kwargs.setdefault("balance_new", kwargs["checkout"].balance)
+ kwargs.setdefault("balance_old", kwargs["checkout"].balance)
+ kwargs.setdefault("amount_taken", Decimal(0))
+
+ return CheckoutStatement.objects.create(**kwargs)
+
+
+def create_article(**kwargs):
+ kwargs.setdefault("name", "Article")
+ kwargs.setdefault("price", Decimal("2.50"))
+ kwargs.setdefault("stock", 20)
+ if "category" not in kwargs:
+ kwargs["category"] = create_article_category()
+
+ return Article.objects.create(**kwargs)
+
+
+def create_article_category(**kwargs):
+ kwargs.setdefault("name", "Category")
+ return ArticleCategory.objects.create(**kwargs)
+
+
+def create_inventory(**kwargs):
+ if "by" not in kwargs:
+ try:
+ team_account = Account.objects.get(cofprofile__user__username="team")
+ except Account.DoesNotExist:
+ team_account = create_team().profile.account_kfet
+ kwargs["by"] = team_account
+
+ return Inventory.objects.create(**kwargs)
+
+
+def create_inventory_article(**kwargs):
+ if "inventory" not in kwargs:
+ kwargs["inventory"] = create_inventory()
+ if "article" not in kwargs:
+ kwargs["article"] = create_article()
+ kwargs.setdefault("stock_old", kwargs["article"].stock)
+ kwargs.setdefault("stock_new", kwargs["article"].stock)
+
+ return InventoryArticle.objects.create(**kwargs)
diff --git a/petitscours/__init__.py b/petitscours/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/gestioncof/petits_cours_forms.py b/petitscours/forms.py
similarity index 95%
rename from gestioncof/petits_cours_forms.py
rename to petitscours/forms.py
index b9cfc067..5309b41d 100644
--- a/gestioncof/petits_cours_forms.py
+++ b/petitscours/forms.py
@@ -4,7 +4,7 @@ from django.contrib.auth.models import User
from django.forms import ModelForm
from django.forms.models import BaseInlineFormSet, inlineformset_factory
-from gestioncof.petits_cours_models import PetitCoursAbility, PetitCoursDemande
+from petitscours.models import PetitCoursAbility, PetitCoursDemande
class BaseMatieresFormSet(BaseInlineFormSet):
diff --git a/gestioncof/petits_cours_models.py b/petitscours/models.py
similarity index 77%
rename from gestioncof/petits_cours_models.py
rename to petitscours/models.py
index 40031877..c3bdce2f 100644
--- a/gestioncof/petits_cours_models.py
+++ b/petitscours/models.py
@@ -3,6 +3,7 @@ from functools import reduce
from django.contrib.auth.models import User
from django.db import models
from django.db.models import Min
+from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _
@@ -16,6 +17,7 @@ LEVELS_CHOICES = (
("prepa1styear", _("Prépa 1ère année / L1")),
("prepa2ndyear", _("Prépa 2ème année / L2")),
("licence3", _("Licence 3")),
+ ("master1", _("Master (1ère ou 2ème année)")),
("other", _("Autre (préciser dans les commentaires)")),
)
@@ -27,6 +29,7 @@ class PetitCoursSubject(models.Model):
)
class Meta:
+ app_label = "gestioncof"
verbose_name = "Matière de petits cours"
verbose_name_plural = "Matières des petits cours"
@@ -45,6 +48,7 @@ class PetitCoursAbility(models.Model):
agrege = models.BooleanField(_("Agrégé"), default=False)
class Meta:
+ app_label = "gestioncof"
verbose_name = "Compétence petits cours"
verbose_name_plural = "Compétences des petits cours"
@@ -53,6 +57,12 @@ class PetitCoursAbility(models.Model):
self.user.username, self.matiere, self.niveau
)
+ @cached_property
+ def counter(self) -> int:
+ """Le compteur d'attribution associé au professeur pour cette matière."""
+
+ return PetitCoursAttributionCounter.get_uptodate(self.user, self.matiere).count
+
class PetitCoursDemande(models.Model):
name = models.CharField(_("Nom/prénom"), max_length=200)
@@ -126,7 +136,44 @@ class PetitCoursDemande(models.Model):
candidates = candidates.order_by("?").select_related().all()
yield (matiere, candidates)
+ def get_proposals(self, *, max_candidates: int = None, redo: bool = False):
+ """Calcule une proposition de profs pour la demande.
+
+ Args:
+ max_candidates (optionnel; défaut: `None`): Le nombre maximum de
+ candidats à proposer par demande. Si `None` ou non spécifié,
+ il n'y a pas de limite.
+
+ redo (optionel; défaut: `False`): Détermine si on re-calcule les
+ propositions pour la demande (les professeurs à qui on a déjà
+ proposé cette demande sont exclus).
+
+ Returns:
+ proposals: Le dictionnaire qui associe à chaque matière la liste
+ des professeurs proposés. Les matières pour lesquelles aucun
+ professeur n'est disponible ne sont pas présentes dans
+ `proposals`.
+ unsatisfied: La liste des matières pour lesquelles aucun
+ professeur n'est disponible.
+ """
+
+ proposals = {}
+ unsatisfied = []
+ for matiere, candidates in self.get_candidates(redo=redo):
+ if not candidates:
+ unsatisfied.append(matiere)
+ else:
+ proposals[matiere] = matiere_proposals = []
+
+ candidates = sorted(candidates, key=lambda c: c.counter)
+ candidates = candidates[:max_candidates]
+ for candidate in candidates[:max_candidates]:
+ matiere_proposals.append(candidate.user)
+
+ return proposals, unsatisfied
+
class Meta:
+ app_label = "gestioncof"
verbose_name = "Demande de petits cours"
verbose_name_plural = "Demandes de petits cours"
@@ -147,6 +194,7 @@ class PetitCoursAttribution(models.Model):
selected = models.BooleanField(_("Sélectionné par le demandeur"), default=False)
class Meta:
+ app_label = "gestioncof"
verbose_name = "Attribution de petits cours"
verbose_name_plural = "Attributions de petits cours"
@@ -182,6 +230,7 @@ class PetitCoursAttributionCounter(models.Model):
return counter
class Meta:
+ app_label = "gestioncof"
verbose_name = "Compteur d'attribution de petits cours"
verbose_name_plural = "Compteurs d'attributions de petits cours"
diff --git a/gestioncof/templates/base_title_petitscours.html b/petitscours/templates/petitscours/base_title.html
similarity index 100%
rename from gestioncof/templates/base_title_petitscours.html
rename to petitscours/templates/petitscours/base_title.html
diff --git a/gestioncof/templates/demande-petit-cours.html b/petitscours/templates/petitscours/demande.html
similarity index 100%
rename from gestioncof/templates/demande-petit-cours.html
rename to petitscours/templates/petitscours/demande.html
diff --git a/gestioncof/templates/gestioncof/details_demande_petit_cours.html b/petitscours/templates/petitscours/demande_detail.html
similarity index 93%
rename from gestioncof/templates/gestioncof/details_demande_petit_cours.html
rename to petitscours/templates/petitscours/demande_detail.html
index b51c0dc0..e82a67d4 100644
--- a/gestioncof/templates/gestioncof/details_demande_petit_cours.html
+++ b/petitscours/templates/petitscours/demande_detail.html
@@ -1,11 +1,11 @@
-{% extends "base_title_petitscours.html" %}
+{% extends "petitscours/base_title.html" %}
{% load staticfiles %}
{% block page_size %}col-sm-8{% endblock %}
{% block realcontent %}
Demande de petits cours
- {% include "details_demande_petit_cours_infos.html" %}
+ {% include "petitscours/details_demande_infos.html" %}
Traitée | |
diff --git a/gestioncof/templates/petits_cours_demandes_list.html b/petitscours/templates/petitscours/demande_list.html
similarity index 97%
rename from gestioncof/templates/petits_cours_demandes_list.html
rename to petitscours/templates/petitscours/demande_list.html
index 92e934f5..74654e44 100644
--- a/gestioncof/templates/petits_cours_demandes_list.html
+++ b/petitscours/templates/petitscours/demande_list.html
@@ -1,4 +1,4 @@
-{% extends "base_title_petitscours.html" %}
+{% extends "petitscours/base_title.html" %}
{% load staticfiles %}
{% block realcontent %}
diff --git a/gestioncof/templates/demande-petit-cours-raw.html b/petitscours/templates/petitscours/demande_raw.html
similarity index 100%
rename from gestioncof/templates/demande-petit-cours-raw.html
rename to petitscours/templates/petitscours/demande_raw.html
diff --git a/gestioncof/templates/details_demande_petit_cours_infos.html b/petitscours/templates/petitscours/details_demande_infos.html
similarity index 100%
rename from gestioncof/templates/details_demande_petit_cours_infos.html
rename to petitscours/templates/petitscours/details_demande_infos.html
diff --git a/gestioncof/templates/inscription-petit-cours.html b/petitscours/templates/petitscours/inscription.html
similarity index 98%
rename from gestioncof/templates/inscription-petit-cours.html
rename to petitscours/templates/petitscours/inscription.html
index 4ac0a874..c4920fb3 100644
--- a/gestioncof/templates/inscription-petit-cours.html
+++ b/petitscours/templates/petitscours/inscription.html
@@ -94,7 +94,7 @@ var django = {