diff --git a/readMe.md b/README.md similarity index 66% rename from readMe.md rename to README.md index b50408a..0362734 100644 --- a/readMe.md +++ b/README.md @@ -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. diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..92a7127 --- /dev/null +++ b/conftest.py @@ -0,0 +1,3 @@ +pytest_plugins = [ + "la_chariotte.helpers.fixtures", +] diff --git a/dev-requirements.txt b/dev-requirements.txt index fb0b229..f9cc9c8 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -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 diff --git a/la_chariotte/accounts/__init__.py b/la_chariotte/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/la_chariotte/accounts/apps.py b/la_chariotte/accounts/apps.py new file mode 100644 index 0000000..299b83e --- /dev/null +++ b/la_chariotte/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "la_chariotte.accounts" diff --git a/la_chariotte/accounts/migrations/__init__.py b/la_chariotte/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/la_chariotte/accounts/models.py b/la_chariotte/accounts/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/la_chariotte/accounts/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/la_chariotte/accounts/templates/registration/login.html b/la_chariotte/accounts/templates/registration/login.html new file mode 100644 index 0000000..dacc4b9 --- /dev/null +++ b/la_chariotte/accounts/templates/registration/login.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} + +{% block title %}Connexion{% endblock %} + +{% block content %} +

Connexion

+
+ {% csrf_token %} + {{ form.as_p }} + + Créer un compte + + +{% endblock %} \ No newline at end of file diff --git a/la_chariotte/accounts/templates/registration/signup.html b/la_chariotte/accounts/templates/registration/signup.html new file mode 100644 index 0000000..c049920 --- /dev/null +++ b/la_chariotte/accounts/templates/registration/signup.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} + +{% block title %}Créer un compte{% endblock %} + +{% block content %} +

Créer un compte

+
+ {% csrf_token %} + {{ form.as_p }} + +
+ +{% endblock %} \ No newline at end of file diff --git a/la_chariotte/accounts/urls.py b/la_chariotte/accounts/urls.py new file mode 100644 index 0000000..ebf4479 --- /dev/null +++ b/la_chariotte/accounts/urls.py @@ -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")), +] diff --git a/la_chariotte/accounts/views.py b/la_chariotte/accounts/views.py new file mode 100644 index 0000000..bf85312 --- /dev/null +++ b/la_chariotte/accounts/views.py @@ -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" diff --git a/la_chariotte/helpers/fixtures.py b/la_chariotte/helpers/fixtures.py new file mode 100644 index 0000000..2aa45d3 --- /dev/null +++ b/la_chariotte/helpers/fixtures.py @@ -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 diff --git a/la_chariotte/order/migrations/0011_alter_groupedorder_orga.py b/la_chariotte/order/migrations/0011_alter_groupedorder_orga.py new file mode 100644 index 0000000..626ff35 --- /dev/null +++ b/la_chariotte/order/migrations/0011_alter_groupedorder_orga.py @@ -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 + ), + ), + ] diff --git a/la_chariotte/order/models.py b/la_chariotte/order/models.py index 4c4881f..7422571 100644 --- a/la_chariotte/order/models.py +++ b/la_chariotte/order/models.py @@ -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") diff --git a/la_chariotte/order/templates/order/grouped_order_detail.html b/la_chariotte/order/templates/order/grouped_order_detail.html index 09613de..8ad777f 100644 --- a/la_chariotte/order/templates/order/grouped_order_detail.html +++ b/la_chariotte/order/templates/order/grouped_order_detail.html @@ -11,7 +11,19 @@

Organisateur·ice : {{ grouped_order.orga }}

Date de livraison : {{ grouped_order.delivery_date }}

- les produits disponibles pour cette commande groupée : + {% if not user.is_authenticated %} +

Vous êtes l'organisateur·ice de cette commande groupée ? + + Connectez-vous pour accéder à la page de gestion +

+ {% endif %} + + {% if user == grouped_order.orga %} + + Page de gestion de la commande groupée + {% endif %} + +

les produits disponibles pour cette commande groupée :

- Retour à la page de commande + Retour à la page de commande diff --git a/la_chariotte/order/templates/order/index.html b/la_chariotte/order/templates/order/index.html index 26060cd..fa38737 100644 --- a/la_chariotte/order/templates/order/index.html +++ b/la_chariotte/order/templates/order/index.html @@ -6,7 +6,6 @@

Index des commandes que l'utilisateur·ice connecté·e organise

-

Pour l'instant, index de toutes les commandes groupées qui existent

{% 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 %} diff --git a/la_chariotte/order/tests/test_models.py b/la_chariotte/order/tests/test_models.py index d5fff17..ed8612f 100644 --- a/la_chariotte/order/tests/test_models.py +++ b/la_chariotte/order/tests/test_models.py @@ -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() diff --git a/la_chariotte/order/tests/test_views.py b/la_chariotte/order/tests/test_views.py index 9e241bc..715d857 100644 --- a/la_chariotte/order/tests/test_views.py +++ b/la_chariotte/order/tests/test_views.py @@ -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 diff --git a/la_chariotte/order/urls.py b/la_chariotte/order/urls.py index a769aa3..56d4ad7 100644 --- a/la_chariotte/order/urls.py +++ b/la_chariotte/order/urls.py @@ -9,7 +9,9 @@ urlpatterns = [ "/", views.GroupedOrderDetailView.as_view(), name="grouped_order_detail" ), path( - "/orga", views.GroupedOrderOrgaView.as_view(), name="grouped_order_orga" + "/gerer", + views.GroupedOrderOrgaView.as_view(), + name="grouped_order_overview", ), path("/commander/", views.order, name="order"), ] diff --git a/la_chariotte/order/views.py b/la_chariotte/order/views.py index 8c0e6e1..b117980 100644 --- a/la_chariotte/order/views.py +++ b/la_chariotte/order/views.py @@ -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,)) ) diff --git a/la_chariotte/settings.py b/la_chariotte/settings.py index 159b46b..7bb7f4d 100644 --- a/la_chariotte/settings.py +++ b/la_chariotte/settings.py @@ -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": [ diff --git a/la_chariotte/templates/base.html b/la_chariotte/templates/base.html new file mode 100644 index 0000000..25ceeb2 --- /dev/null +++ b/la_chariotte/templates/base.html @@ -0,0 +1,13 @@ + + + + + {% block title %}{% endblock %} - La Chariotte + + +
+ {% block content %} + {% endblock %} +
+ + \ No newline at end of file diff --git a/la_chariotte/templates/home.html b/la_chariotte/templates/home.html new file mode 100644 index 0000000..8ae7be6 --- /dev/null +++ b/la_chariotte/templates/home.html @@ -0,0 +1,19 @@ +{% extends 'base.html' %} + +{% block title %}Accueil{% endblock %} + +{% block content %} +{% if user.is_authenticated %} + Hi {{ user.username }}! +

+ Mes commandes groupées +

+

+ Se déconnecter +

+{% else %} +

You are not logged in

+ Se connecter +{% endif %} +{% endblock %} + diff --git a/la_chariotte/urls.py b/la_chariotte/urls.py index cdef8bf..0a86d20 100644 --- a/la_chariotte/urls.py +++ b/la_chariotte/urls.py @@ -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"), ] diff --git a/pyproject.toml b/pyproject.toml index 68763da..63fdb87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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]