Merge branch 'authentication' into 'develop'

Authentification

See merge request hashbangfr/la_chariotte!6
This commit is contained in:
Laetitia Getti 2023-04-27 13:31:52 +00:00
commit 0c7f489331
26 changed files with 359 additions and 38 deletions

View file

@ -33,3 +33,10 @@ Si il y a des erreurs BLACK, on peut lancer black pour linter le code :
```bash
black .
```
## Architecture de l'application
Les différentes applications Django créées sont :
- Order, pour gérer tout ce qui tourne autour des commandes
- Accounts, pour gérer la création de comptes. Pour la connexion, la déconnexion et le changement de mot de passe, on utilise l'application auth intégrée à Django.

3
conftest.py Normal file
View file

@ -0,0 +1,3 @@
pytest_plugins = [
"la_chariotte.helpers.fixtures",
]

View file

@ -12,6 +12,12 @@ black==23.3.0
# via pytest-black
build==0.10.0
# via pip-tools
chardet==4.0.0
# via diff-cover
# via flake8-black
build==0.10.0
# via pip-tools
# via pytest-black
chardet==4.0.0
# via diff-cover
click==8.1.3
@ -32,6 +38,16 @@ inflect==3.0.2
# via
# diff-cover
# jinja2-pluralize
flake8==6.0.0
# via flake8-black
flake8-black==0.3.6
# via la-chariotte (pyproject.toml)
importlib-metadata==6.1.0
# via inflect
inflect==3.0.2
# via
# diff-cover
# jinja2-pluralize
iniconfig==2.0.0
# via pytest
isort==5.12.0
@ -44,6 +60,8 @@ jinja2-pluralize==0.3.0
# via diff-cover
markupsafe==2.1.2
# via jinja2
mccabe==0.7.0
# via flake8
mypy-extensions==1.0.0
# via black
packaging==23.0
@ -63,6 +81,13 @@ pluggy==1.0.0
# pytest
psycopg2-binary==2.9.6
# via la-chariotte (pyproject.toml)
pygments==2.14.0
# via diff-cover
# via la-chariotte (pyproject.toml)
pycodestyle==2.10.0
# via flake8
pyflakes==3.0.1
# via flake8
pygments==2.14.0
# via diff-cover
pyproject-hooks==1.0.0
@ -95,6 +120,14 @@ tomli==2.0.1
# pytest
wheel==0.40.0
# via pip-tools
zipp==3.15.0
# via importlib-metadata
# coverage
# flake8-black
# pyproject-hooks
# pytest
wheel==0.40.0
# via pip-tools
zipp==3.15.0
# via importlib-metadata

View file

View file

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

View file

@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View file

@ -0,0 +1,14 @@
{% extends 'base.html' %}
{% block title %}Connexion{% endblock %}
{% block content %}
<h2>Connexion</h2>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Se connecter</button>
<a href="{% url 'accounts:signup' %}">Créer un compte</button>
</form>
{% endblock %}

View file

@ -0,0 +1,13 @@
{% extends 'base.html' %}
{% block title %}Créer un compte{% endblock %}
{% block content %}
<h2>Créer un compte</h2>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Valider</button>
</form>
{% endblock %}

View file

@ -0,0 +1,9 @@
from django.urls import include, path
from . import views
app_name = "accounts"
urlpatterns = [
path("inscription/", views.SignUpView.as_view(), name="signup"),
path("", include("django.contrib.auth.urls")),
]

View file

@ -0,0 +1,9 @@
from django.contrib.auth.forms import UserCreationForm
from django.urls import reverse_lazy
from django.views import generic
class SignUpView(generic.CreateView):
form_class = UserCreationForm
success_url = reverse_lazy("accounts:login")
template_name = "registration/signup.html"

View file

@ -0,0 +1,18 @@
import pytest
@pytest.fixture
def client_log(client, django_user_model):
username = "test@user.fr"
password = "azertypassword"
user = django_user_model.objects.create_user(username=username, password=password)
client.login(username=username, password=password)
return client
@pytest.fixture
def other_user(django_user_model):
username = "other@user.fr"
password = "azertypassword"
user = django_user_model.objects.create_user(username=username, password=password)
return user

View file

@ -0,0 +1,22 @@
# Generated by Django 4.2 on 2023-04-18 13:08
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("order", "0010_rename_grouped_order_groupedorder"),
]
operations = [
migrations.AlterField(
model_name="groupedorder",
name="orga",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
]

View file

@ -1,10 +1,15 @@
from django.contrib.auth.models import User
from django.db import models
from django.utils import timezone
class GroupedOrder(models.Model):
name = models.CharField(max_length=100, null=True) # optionnal
orga = models.CharField(max_length=100) # a changer, utiliser ForeignKey de user
name = models.CharField(
max_length=100, null=True, verbose_name="Titre de la commande"
) # optionnal
orga = models.ForeignKey(
User, on_delete=models.CASCADE, verbose_name="Organisateur·ice"
)
delivery_date = models.DateField("Date de livraison")
deadline = models.DateTimeField("Date limite de commande")

View file

@ -11,7 +11,19 @@
<p>Organisateur·ice : {{ grouped_order.orga }}</p>
<p>Date de livraison : {{ grouped_order.delivery_date }}</p>
les produits disponibles pour cette commande groupée :
{% if not user.is_authenticated %}
<p>Vous êtes l'organisateur·ice de cette commande groupée ?
<a href="{% url 'order:grouped_order_overview' grouped_order.id %}">
Connectez-vous pour accéder à la page de gestion</a>
</p>
{% endif %}
{% if user == grouped_order.orga %}
<a href="{% url 'order:grouped_order_overview' grouped_order.id %}">
Page de gestion de la commande groupée</a>
{% endif %}
<p>les produits disponibles pour cette commande groupée : </p>
<ul>
{% for item in grouped_order.item_set.all %}

View file

@ -29,6 +29,6 @@
{% endfor %}
</ul>
<a href={% url 'order:order' grouped_order.id %}>Retour à la page de commande</a>
<a href={% url 'order:grouped_order_detail' grouped_order.id %}>Retour à la page de commande</a>
</body>
</html>

View file

@ -6,7 +6,6 @@
</head>
<body>
<p>Index des commandes que l'utilisateur·ice connecté·e organise</p>
<p>Pour l'instant, index de toutes les commandes groupées qui existent</p>
{% if grouped_order_list.incoming_grouped_orders or grouped_order_list.crossed_deadline_grouped_orders or grouped_order_list.old_grouped_orders %}
{% if grouped_order_list.incoming_grouped_orders %}

View file

@ -13,6 +13,7 @@ class TestGroupedOrdersModel:
is_ongoing() returns True if the deadline is not crossed
"""
deadline = timezone.now() + datetime.timedelta(days=10)
ongoing_gr_order = GroupedOrder(deadline=deadline)
assert ongoing_gr_order.is_ongoing()

View file

@ -1,6 +1,8 @@
import datetime
import pytest
from django.contrib import auth
from django.contrib.auth.models import User
from django.urls import reverse
from django.utils import timezone
@ -9,23 +11,35 @@ from la_chariotte.order.models import GroupedOrder, Item, Order
pytestmark = pytest.mark.django_db
def create_grouped_order(days_before_delivery_date, days_before_deadline, name):
def create_grouped_order(
days_before_delivery_date, days_before_deadline, name, orga_user
):
"""
Creates a grouped order.
"""
date = timezone.now().date() + datetime.timedelta(days=days_before_delivery_date)
deadline = timezone.now() + datetime.timedelta(days=days_before_deadline)
return GroupedOrder.objects.create(
name=name, orga="test orga", delivery_date=date, deadline=deadline
name=name, orga=orga_user, delivery_date=date, deadline=deadline
)
class TestGroupedOrderIndexView:
def test_no_grouped_orders(self, client):
def test_anonymous_user_redirection(self, client):
"""
If the user is anonymous, they are redirected to login view
"""
assert auth.get_user(client).is_anonymous
response = client.get(reverse("order:index"))
assert response.status_code == 302
assert response.url.startswith(reverse("accounts:login"))
assert response.url.endswith(reverse("order:index"))
def test_no_grouped_orders(self, client_log):
"""
If no grouped order exist, an appropriate message is displayed
"""
response = client.get(reverse("order:index"))
response = client_log.get(reverse("order:index"))
assert response.status_code == 200
assert "Pas de commande groupée pour l'instant" in response.content.decode()
assert len(response.context["grouped_order_list"]["old_grouped_orders"]) == 0
@ -41,22 +55,29 @@ class TestGroupedOrderIndexView:
len(response.context["grouped_order_list"]["incoming_grouped_orders"]) == 0
)
def test_grouped_orders_in_right_section(self, client):
def test_grouped_orders_in_right_section(self, client_log):
"""
According to their delivery date and deadline, grouped orders are placed in the correct section : several gr orders
"""
future_grouped_order = create_grouped_order(
days_before_delivery_date=5, days_before_deadline=2, name="future"
days_before_delivery_date=5,
days_before_deadline=2,
name="future",
orga_user=auth.get_user(client_log),
)
crossed_deadline_gr_order = create_grouped_order(
days_before_delivery_date=2,
days_before_deadline=-1,
name="crossed deadline",
orga_user=auth.get_user(client_log),
)
old_gr_order = create_grouped_order(
days_before_delivery_date=-1, days_before_deadline=-3, name="old"
days_before_delivery_date=-1,
days_before_deadline=-3,
name="old",
orga_user=auth.get_user(client_log),
)
response = client.get(reverse("order:index"))
response = client_log.get(reverse("order:index"))
assert response.status_code == 200
assert "Pas de commande groupée pour l'instant" not in response.content.decode()
assert "Commandes groupées à venir" in response.content.decode()
@ -87,14 +108,17 @@ class TestGroupedOrderIndexView:
== future_grouped_order
)
def test_grouped_orders_in_right_section__with_only_old(self, client):
def test_grouped_orders_in_right_section__with_only_old(self, client_log):
"""
According to their delivery date and deadline, grouped orders are placed in correct section : only old gr order
"""
old_gr_order = create_grouped_order(
days_before_delivery_date=-1, days_before_deadline=-3, name="passée"
days_before_delivery_date=-1,
days_before_deadline=-3,
name="passée",
orga_user=auth.get_user(client_log),
)
response = client.get(reverse("order:index"))
response = client_log.get(reverse("order:index"))
assert response.status_code == 200
assert "Pas de commande groupée pour l'instant" not in response.content.decode()
assert "Commandes groupées à venir" not in response.content.decode()
@ -117,14 +141,17 @@ class TestGroupedOrderIndexView:
== old_gr_order
)
def test_grouped_orders_in_right_section__with_only_future(self, client):
def test_grouped_orders_in_right_section__with_only_future(self, client_log):
"""
According to their delivery date and deadline, grouped orders are placed in correct section : only incoming gr order
"""
future_grouped_order = create_grouped_order(
days_before_delivery_date=5, days_before_deadline=2, name="future"
days_before_delivery_date=5,
days_before_deadline=2,
name="future",
orga_user=auth.get_user(client_log),
)
response = client.get(reverse("order:index"))
response = client_log.get(reverse("order:index"))
assert response.status_code == 200
assert "Pas de commande groupée pour l'instant" not in response.content.decode()
assert "Commandes groupées à venir" in response.content.decode()
@ -147,14 +174,45 @@ class TestGroupedOrderIndexView:
== future_grouped_order
)
def test_gr_order_not_organized_by_logged_user(self, client_log, other_user):
"""
If the logged user is not the organizer of a grouped order they don't see it on IndexView
"""
logged_user = auth.get_user(client_log)
future_grouped_order = create_grouped_order(
days_before_delivery_date=5,
days_before_deadline=2,
name="future",
orga_user=other_user,
)
assert future_grouped_order.orga is not logged_user
response = client_log.get(reverse("order:index"))
assert response.status_code == 200
assert "Pas de commande groupée pour l'instant" in response.content.decode()
assert len(response.context["grouped_order_list"]["old_grouped_orders"]) == 0
assert (
len(
response.context["grouped_order_list"][
"crossed_deadline_grouped_orders"
]
)
== 0
)
assert (
len(response.context["grouped_order_list"]["incoming_grouped_orders"]) == 0
)
class TestGroupedOrderDetailView:
def test_order_item(self, client):
def test_order_item(self, client, other_user):
"""
From the OrderDetailView, we order an item using the order form, and it creates an Order woth and Ordered_item inside
"""
grouped_order = create_grouped_order(
days_before_delivery_date=5, days_before_deadline=2, name="gr order test"
days_before_delivery_date=5,
days_before_deadline=2,
name="gr order test",
orga_user=other_user,
)
item = Item.objects.create(name="test item", grouped_order=grouped_order)
detail_url = reverse(
@ -182,7 +240,7 @@ class TestGroupedOrderDetailView:
)
assert response.status_code == 302
assert response.url == reverse(
"order:grouped_order_orga",
"order:grouped_order_overview",
kwargs={
"pk": grouped_order.pk,
},
@ -191,3 +249,60 @@ class TestGroupedOrderDetailView:
assert item.ordered_nb == 1
order = Order.objects.first()
assert order.ordered_items.count() == 1
class TestGroupedOrderOrgaView:
def test_user_not_logged_redirect(self, client, other_user):
"""
A user that is not logged cannot see the GroupedOrderOrgaView. They get redirected to the login view
"""
grouped_order = create_grouped_order(
days_before_delivery_date=5,
days_before_deadline=2,
name="gr order test",
orga_user=other_user,
)
orga_view_url = reverse(
"order:grouped_order_overview",
kwargs={
"pk": grouped_order.pk,
},
)
assert auth.get_user(client).is_anonymous
response = client.get(orga_view_url)
assert response.status_code == 302
assert response.url.startswith(reverse("accounts:login"))
assert response.url.endswith(
reverse(
"order:grouped_order_overview",
kwargs={
"pk": grouped_order.pk,
},
)
)
def test_user_not_orga_redirect(self, client_log, other_user):
"""
A user that is not orga cannot see the GroupedOrderOrgaView.
They get a 403 forbidden error
"""
grouped_order = create_grouped_order(
days_before_delivery_date=5,
days_before_deadline=2,
name="gr order test",
orga_user=other_user,
)
orga_view_url = reverse(
"order:grouped_order_overview",
kwargs={
"pk": grouped_order.pk,
},
)
detail_view_url = reverse(
"order:grouped_order_detail",
kwargs={
"pk": grouped_order.pk,
},
)
response = client_log.get(orga_view_url)
assert response.status_code == 403

View file

@ -9,7 +9,9 @@ urlpatterns = [
"<int:pk>/", views.GroupedOrderDetailView.as_view(), name="grouped_order_detail"
),
path(
"<int:pk>/orga", views.GroupedOrderOrgaView.as_view(), name="grouped_order_orga"
"<int:pk>/gerer",
views.GroupedOrderOrgaView.as_view(),
name="grouped_order_overview",
),
path("<int:grouped_order_id>/commander/", views.order, name="order"),
]

View file

@ -1,25 +1,29 @@
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.views import generic
from .models import GroupedOrder, Item, Order, OrderedItem
class IndexView(generic.ListView):
"""Vue de toutes les commandes groupées existantes - plus tard, de toutes les commandes groupées de l'utilisateur connecté"""
class IndexView(LoginRequiredMixin, generic.ListView):
"""View showing all the grouped orders organized by the authenticated user"""
template_name = "order/index.html"
context_object_name = "grouped_order_list"
def get_queryset(self):
"""Only grouped orders organized by logged user"""
logged_user_grouped_orders = GroupedOrder.objects.filter(orga=self.request.user)
"""3 grouped_order status : incoming, crossed_deadline, and old"""
now = timezone.now()
today = now.date()
"""Return the 5 most recent old grouped orders"""
old_grouped_orders = GroupedOrder.objects.filter(
old_grouped_orders = logged_user_grouped_orders.filter(
# is_to_be_delivered=False
delivery_date__lt=today
).order_by("-delivery_date")[
@ -28,13 +32,13 @@ class IndexView(generic.ListView):
"""Return all grouped orders, that have crossed their ordering deadline but the delivery date is still to come"""
crossed_dealine_grouped_orders = (
GroupedOrder.objects.filter(delivery_date__gte=today)
logged_user_grouped_orders.filter(delivery_date__gte=today)
.filter(deadline__lt=now)
.order_by("-delivery_date")
) # delivery date >= today (not delivered) and deadline < today (we cannot order)
"""Return all incoming grouped orders"""
incoming_grouped_orders = GroupedOrder.objects.filter(
incoming_grouped_orders = logged_user_grouped_orders.filter(
deadline__gte=now
).order_by(
"deadline"
@ -47,18 +51,23 @@ class IndexView(generic.ListView):
class GroupedOrderDetailView(generic.DetailView):
"""Vue de détail d'une commande groupée - possibilité de commander si elle est en cours"""
"""Detail view for a grouped order - possibility to order if it is ongoing - No permissions restriction"""
model = GroupedOrder
template_name = "order/grouped_order_detail.html"
context_object_name = "grouped_order"
class GroupedOrderOrgaView(generic.DetailView):
"""Vue de supervision d'une commande groupée"""
class GroupedOrderOrgaView(UserPassesTestMixin, generic.DetailView):
"""Overview of a grouped order, for the organizer"""
model = GroupedOrder
template_name = "order/grouped_order_orga.html"
template_name = "order/grouped_order_overview.html"
context_object_name = "grouped_order"
def test_func(self):
"""Accessible only if the requesting user is the grouped order organizer"""
return self.get_object().orga == self.request.user
def order(
@ -86,7 +95,7 @@ def order(
# with POST data. This prevents data from being posted twice if a
# user hits the Back button.
return HttpResponseRedirect(
reverse("order:grouped_order_orga", args=(grouped_order.pk,))
reverse("order:grouped_order_overview", args=(grouped_order.pk,))
)

View file

@ -32,6 +32,7 @@ ALLOWED_HOSTS = []
INSTALLED_APPS = [
"la_chariotte.order",
"la_chariotte.accounts",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
@ -52,10 +53,14 @@ MIDDLEWARE = [
ROOT_URLCONF = "la_chariotte.urls"
LOGIN_URL = "accounts:login"
LOGIN_REDIRECT_URL = "home"
LOGOUT_REDIRECT_URL = "home"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"DIRS": [BASE_DIR / "la_chariotte" / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<title>{% block title %}{% endblock %} - La Chariotte</title>
</head>
<body>
<main>
{% block content %}
{% endblock %}
</main>
</body>
</html>

View file

@ -0,0 +1,19 @@
{% extends 'base.html' %}
{% block title %}Accueil{% endblock %}
{% block content %}
{% if user.is_authenticated %}
Hi {{ user.username }}!
<p>
<a href="{% url 'order:index' %}">Mes commandes groupées</a>
</p>
<p>
<a href="{% url 'accounts:logout' %}">Se déconnecter</a>
</p>
{% else %}
<p>You are not logged in</p>
<a href="{% url 'accounts:login' %}">Se connecter</a>
{% endif %}
{% endblock %}

View file

@ -15,8 +15,11 @@ Including another URLconf
"""
from django.contrib import admin
from django.urls import include, path
from django.views.generic.base import TemplateView
urlpatterns = [
path("admin/", admin.site.urls),
path("commande/", include("la_chariotte.order.urls")),
path("comptes/", include("la_chariotte.accounts.urls")),
path("", TemplateView.as_view(template_name="home.html"), name="home"),
]

View file

@ -1,13 +1,14 @@
[project]
name = "la chariotte"
name = "la_chariotte"
version = "0.0.1"
description = "Web application for organising grouped orders"
authors = [{name = "Laetitia Getti", email = "laetitia@chariotte.fr"}]
readme = "readMe.md"
license = {file = "LICENSE"}
authors = [{name = "Hashbang", email = "support@hashbang.fr"}]
classifiers = ["License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)"]
dependencies = [
"django>=4,<5",
"psycopg2>=2,<3",
"psycopg2-binary>=2,<3",
]
[build-system]