campuseats 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- campuseats/__init__.py +0 -0
- campuseats/__main__.py +25 -0
- campuseats/app/__init__.py +0 -0
- campuseats/app/admin.py +47 -0
- campuseats/app/apps.py +6 -0
- campuseats/app/authentication.py +5 -0
- campuseats/app/exceptions.py +31 -0
- campuseats/app/migrations/0001_initial.py +129 -0
- campuseats/app/migrations/__init__.py +0 -0
- campuseats/app/models.py +148 -0
- campuseats/app/permissions.py +47 -0
- campuseats/app/serializers.py +84 -0
- campuseats/app/tests.py +68 -0
- campuseats/app/urls.py +27 -0
- campuseats/app/views.py +179 -0
- campuseats/backend/__init__.py +0 -0
- campuseats/backend/asgi.py +16 -0
- campuseats/backend/settings.py +153 -0
- campuseats/backend/urls.py +26 -0
- campuseats/backend/wsgi.py +16 -0
- campuseats-0.1.0.dist-info/METADATA +64 -0
- campuseats-0.1.0.dist-info/RECORD +25 -0
- campuseats-0.1.0.dist-info/WHEEL +5 -0
- campuseats-0.1.0.dist-info/entry_points.txt +2 -0
- campuseats-0.1.0.dist-info/top_level.txt +1 -0
campuseats/__init__.py
ADDED
|
File without changes
|
campuseats/__main__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Console entry point: run Django management commands for CampusEats.
|
|
3
|
+
|
|
4
|
+
Installed as the `campuseats` command (see [project.scripts] in pyproject.toml),
|
|
5
|
+
so `campuseats migrate`, `campuseats runserver`, etc. work after `pip install`.
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main():
|
|
12
|
+
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'campuseats.backend.settings')
|
|
13
|
+
try:
|
|
14
|
+
from django.core.management import execute_from_command_line
|
|
15
|
+
except ImportError as exc:
|
|
16
|
+
raise ImportError(
|
|
17
|
+
"Couldn't import Django. Are you sure it's installed and "
|
|
18
|
+
"available on your PYTHONPATH environment variable? Did you "
|
|
19
|
+
"forget to activate a virtual environment?"
|
|
20
|
+
) from exc
|
|
21
|
+
execute_from_command_line(sys.argv)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if __name__ == '__main__':
|
|
25
|
+
main()
|
|
File without changes
|
campuseats/app/admin.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from django.contrib import admin
|
|
2
|
+
|
|
3
|
+
from .models import User, Location, MealOrder, Courier, CourierHub
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@admin.register(User)
|
|
7
|
+
class UserAdmin(admin.ModelAdmin):
|
|
8
|
+
list_display = ['id', 'username', 'role', 'is_active', 'created_at']
|
|
9
|
+
list_filter = ['role', 'is_active', 'created_at']
|
|
10
|
+
search_fields = ['username']
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@admin.register(Location)
|
|
14
|
+
class LocationAdmin(admin.ModelAdmin):
|
|
15
|
+
list_display = ['id', 'title', 'latitude', 'longitude']
|
|
16
|
+
search_fields = ['title']
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@admin.register(CourierHub)
|
|
20
|
+
class CourierHubAdmin(admin.ModelAdmin):
|
|
21
|
+
list_display = ['hub_id', 'name', 'number_free_couriers', 'created_at']
|
|
22
|
+
list_filter = ['created_at']
|
|
23
|
+
search_fields = ['name']
|
|
24
|
+
readonly_fields = ['number_free_couriers', 'created_at']
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@admin.register(Courier)
|
|
28
|
+
class CourierAdmin(admin.ModelAdmin):
|
|
29
|
+
list_display = ['courier_id', 'max_weight', 'status', 'hub', 'status_changed_at']
|
|
30
|
+
list_filter = ['status', 'hub', 'max_weight']
|
|
31
|
+
search_fields = ['courier_id']
|
|
32
|
+
readonly_fields = ['status_changed_at', 'created_at']
|
|
33
|
+
actions = ['send_to_service']
|
|
34
|
+
|
|
35
|
+
@admin.action(description='Перевести в тех. обслуживание')
|
|
36
|
+
def send_to_service(self, request, queryset):
|
|
37
|
+
for courier in queryset:
|
|
38
|
+
if courier.status != 2:
|
|
39
|
+
courier.mark_service()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@admin.register(MealOrder)
|
|
43
|
+
class MealOrderAdmin(admin.ModelAdmin):
|
|
44
|
+
list_display = ['id', 'title', 'weight', 'user', 'courier', 'status', 'created_at']
|
|
45
|
+
list_filter = ['status', 'weight']
|
|
46
|
+
search_fields = ['title']
|
|
47
|
+
date_hierarchy = 'created_at'
|
campuseats/app/apps.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from rest_framework import status
|
|
2
|
+
from rest_framework.exceptions import APIException
|
|
3
|
+
from rest_framework.response import Response
|
|
4
|
+
from rest_framework.views import exception_handler
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class NoFreeCouriers(APIException):
|
|
8
|
+
status_code = status.HTTP_409_CONFLICT
|
|
9
|
+
default_detail = 'No free couriers'
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def custom_exception_handler(exc, context):
|
|
13
|
+
response = exception_handler(exc, context)
|
|
14
|
+
if response is None:
|
|
15
|
+
return response
|
|
16
|
+
|
|
17
|
+
code = response.status_code
|
|
18
|
+
|
|
19
|
+
if code == 400:
|
|
20
|
+
return Response({'msg': 'Validation error', 'code': 422, 'errors': response.data},
|
|
21
|
+
status=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
|
22
|
+
if code == 401:
|
|
23
|
+
return Response({'msg': 'Нет или невалидный токен', 'code': 401}, status=401)
|
|
24
|
+
if code == 403:
|
|
25
|
+
return Response({'msg': 'Недостаточно прав', 'code': 403}, status=403)
|
|
26
|
+
if code == 404:
|
|
27
|
+
return Response({'msg': 'Not Found', 'code': 404}, status=404)
|
|
28
|
+
if code == 409:
|
|
29
|
+
return Response({'msg': 'No free couriers', 'code': 409}, status=409)
|
|
30
|
+
|
|
31
|
+
return response
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Generated by Django 5.1.4 on 2026-07-05 10:00
|
|
2
|
+
|
|
3
|
+
import django.contrib.auth.models
|
|
4
|
+
import django.db.models.deletion
|
|
5
|
+
import django.utils.timezone
|
|
6
|
+
import uuid
|
|
7
|
+
from django.conf import settings
|
|
8
|
+
from django.db import migrations, models
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Migration(migrations.Migration):
|
|
12
|
+
|
|
13
|
+
initial = True
|
|
14
|
+
|
|
15
|
+
dependencies = [
|
|
16
|
+
('auth', '0012_alter_user_first_name_max_length'),
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
operations = [
|
|
20
|
+
migrations.CreateModel(
|
|
21
|
+
name='User',
|
|
22
|
+
fields=[
|
|
23
|
+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
24
|
+
('password', models.CharField(max_length=128, verbose_name='password')),
|
|
25
|
+
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
|
26
|
+
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
|
27
|
+
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
|
28
|
+
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
|
29
|
+
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
|
30
|
+
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
|
31
|
+
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
|
32
|
+
('username', models.CharField(max_length=256, unique=True)),
|
|
33
|
+
('role', models.IntegerField(choices=[(0, 'admin'), (1, 'kitchen'), (2, 'student')], default=2)),
|
|
34
|
+
('is_active', models.BooleanField(default=True)),
|
|
35
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
36
|
+
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
|
37
|
+
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
|
38
|
+
],
|
|
39
|
+
options={
|
|
40
|
+
'verbose_name': 'user',
|
|
41
|
+
'verbose_name_plural': 'users',
|
|
42
|
+
'abstract': False,
|
|
43
|
+
},
|
|
44
|
+
managers=[
|
|
45
|
+
('objects', django.contrib.auth.models.UserManager()),
|
|
46
|
+
],
|
|
47
|
+
),
|
|
48
|
+
migrations.CreateModel(
|
|
49
|
+
name='CourierHub',
|
|
50
|
+
fields=[
|
|
51
|
+
('hub_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
|
52
|
+
('name', models.CharField(max_length=256, unique=True)),
|
|
53
|
+
('number_free_couriers', models.PositiveIntegerField(default=0)),
|
|
54
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
55
|
+
],
|
|
56
|
+
options={
|
|
57
|
+
'indexes': [models.Index(fields=['number_free_couriers'], name='app_courier_number__156560_idx')],
|
|
58
|
+
},
|
|
59
|
+
),
|
|
60
|
+
migrations.CreateModel(
|
|
61
|
+
name='Location',
|
|
62
|
+
fields=[
|
|
63
|
+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
64
|
+
('title', models.CharField(max_length=256, unique=True)),
|
|
65
|
+
('latitude', models.DecimalField(decimal_places=6, max_digits=9)),
|
|
66
|
+
('longitude', models.DecimalField(decimal_places=6, max_digits=9)),
|
|
67
|
+
],
|
|
68
|
+
options={
|
|
69
|
+
'indexes': [models.Index(fields=['latitude'], name='app_locatio_latitud_fe7f4e_idx'), models.Index(fields=['longitude'], name='app_locatio_longitu_af28cd_idx')],
|
|
70
|
+
},
|
|
71
|
+
),
|
|
72
|
+
migrations.CreateModel(
|
|
73
|
+
name='Courier',
|
|
74
|
+
fields=[
|
|
75
|
+
('courier_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
|
76
|
+
('max_weight', models.PositiveIntegerField()),
|
|
77
|
+
('status', models.IntegerField(choices=[(0, 'Свободен'), (1, 'В пути'), (2, 'Тех. обслуживание')], default=0)),
|
|
78
|
+
('status_changed_at', models.DateTimeField(default=django.utils.timezone.now)),
|
|
79
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
80
|
+
('hub', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.courierhub')),
|
|
81
|
+
('end_location', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='end_location', to='app.location')),
|
|
82
|
+
('start_location', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='start_location', to='app.location')),
|
|
83
|
+
],
|
|
84
|
+
),
|
|
85
|
+
migrations.CreateModel(
|
|
86
|
+
name='MealOrder',
|
|
87
|
+
fields=[
|
|
88
|
+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
89
|
+
('title', models.CharField(max_length=256, unique=True)),
|
|
90
|
+
('weight', models.PositiveIntegerField()),
|
|
91
|
+
('idempotency_key', models.CharField(blank=True, max_length=256, null=True, unique=True)),
|
|
92
|
+
('status', models.IntegerField(choices=[(0, 'created'), (1, 'assigned'), (2, 'delivered'), (3, 'cancelled')], default=0)),
|
|
93
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
94
|
+
('courier', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='app.courier')),
|
|
95
|
+
('end_location', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='end_location_order', to='app.location')),
|
|
96
|
+
('start_location', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='start_location_order', to='app.location')),
|
|
97
|
+
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
|
98
|
+
],
|
|
99
|
+
),
|
|
100
|
+
migrations.AddField(
|
|
101
|
+
model_name='courier',
|
|
102
|
+
name='order',
|
|
103
|
+
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order', to='app.mealorder'),
|
|
104
|
+
),
|
|
105
|
+
migrations.AddIndex(
|
|
106
|
+
model_name='mealorder',
|
|
107
|
+
index=models.Index(fields=['weight'], name='app_mealord_weight_0e6eea_idx'),
|
|
108
|
+
),
|
|
109
|
+
migrations.AddIndex(
|
|
110
|
+
model_name='mealorder',
|
|
111
|
+
index=models.Index(fields=['user'], name='app_mealord_user_id_b14336_idx'),
|
|
112
|
+
),
|
|
113
|
+
migrations.AddIndex(
|
|
114
|
+
model_name='mealorder',
|
|
115
|
+
index=models.Index(fields=['status', 'created_at'], name='app_mealord_status_14deff_idx'),
|
|
116
|
+
),
|
|
117
|
+
migrations.AddIndex(
|
|
118
|
+
model_name='courier',
|
|
119
|
+
index=models.Index(fields=['hub'], name='app_courier_hub_id_69d3e9_idx'),
|
|
120
|
+
),
|
|
121
|
+
migrations.AddIndex(
|
|
122
|
+
model_name='courier',
|
|
123
|
+
index=models.Index(fields=['status'], name='app_courier_status_c1044b_idx'),
|
|
124
|
+
),
|
|
125
|
+
migrations.AddIndex(
|
|
126
|
+
model_name='courier',
|
|
127
|
+
index=models.Index(fields=['status', 'hub'], name='app_courier_status_76c6f2_idx'),
|
|
128
|
+
),
|
|
129
|
+
]
|
|
File without changes
|
campuseats/app/models.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
from django.db import models
|
|
4
|
+
from django.contrib.auth.models import AbstractUser
|
|
5
|
+
from django.utils import timezone
|
|
6
|
+
from rest_framework.serializers import ValidationError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class User(AbstractUser):
|
|
10
|
+
username = models.CharField(unique=True, max_length=256)
|
|
11
|
+
role = models.IntegerField(
|
|
12
|
+
choices=(
|
|
13
|
+
(0, 'admin'),
|
|
14
|
+
(1, 'kitchen'),
|
|
15
|
+
(2, 'student'),
|
|
16
|
+
), default=2
|
|
17
|
+
)
|
|
18
|
+
is_active = models.BooleanField(default=True)
|
|
19
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Location(models.Model):
|
|
23
|
+
title = models.CharField(unique=True, max_length=256)
|
|
24
|
+
latitude = models.DecimalField(max_digits=9, decimal_places=6)
|
|
25
|
+
longitude = models.DecimalField(max_digits=9, decimal_places=6)
|
|
26
|
+
|
|
27
|
+
class Meta:
|
|
28
|
+
indexes = [
|
|
29
|
+
models.Index(fields=['latitude']),
|
|
30
|
+
models.Index(fields=['longitude'])
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
def __str__(self):
|
|
34
|
+
return self.title
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CourierHub(models.Model):
|
|
38
|
+
hub_id = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False)
|
|
39
|
+
name = models.CharField(unique=True, max_length=256)
|
|
40
|
+
number_free_couriers = models.PositiveIntegerField(default=0)
|
|
41
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
42
|
+
|
|
43
|
+
def update_number_free_couriers(self):
|
|
44
|
+
self.number_free_couriers = Courier.objects.filter(
|
|
45
|
+
status=0, hub=self
|
|
46
|
+
).count()
|
|
47
|
+
self.save(update_fields=['number_free_couriers'])
|
|
48
|
+
|
|
49
|
+
def __str__(self):
|
|
50
|
+
return self.name
|
|
51
|
+
|
|
52
|
+
class Meta:
|
|
53
|
+
indexes = [
|
|
54
|
+
models.Index(fields=['number_free_couriers'])
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Courier(models.Model):
|
|
59
|
+
courier_id = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False)
|
|
60
|
+
max_weight = models.PositiveIntegerField()
|
|
61
|
+
status = models.IntegerField(
|
|
62
|
+
choices=(
|
|
63
|
+
(0, 'Свободен'),
|
|
64
|
+
(1, 'В пути'),
|
|
65
|
+
(2, 'Тех. обслуживание'),
|
|
66
|
+
), default=0
|
|
67
|
+
)
|
|
68
|
+
order = models.ForeignKey("MealOrder", models.SET_NULL, null=True, blank=True, related_name='order')
|
|
69
|
+
start_location = models.ForeignKey(Location, models.CASCADE, related_name='start_location')
|
|
70
|
+
end_location = models.ForeignKey(Location, models.CASCADE, related_name='end_location')
|
|
71
|
+
hub = models.ForeignKey(CourierHub, models.CASCADE)
|
|
72
|
+
status_changed_at = models.DateTimeField(default=timezone.now)
|
|
73
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
74
|
+
|
|
75
|
+
def clean(self):
|
|
76
|
+
if self.start_location_id == self.end_location_id:
|
|
77
|
+
raise ValidationError({
|
|
78
|
+
"msg": 'end и start не могут совпадать'
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
def mark_in_transit(self):
|
|
82
|
+
if self.status == 1:
|
|
83
|
+
raise ValidationError({'msg': 'Курьер и так в пути'})
|
|
84
|
+
self.status = 1
|
|
85
|
+
self.status_changed_at = timezone.now()
|
|
86
|
+
self.save()
|
|
87
|
+
self.hub.update_number_free_couriers()
|
|
88
|
+
|
|
89
|
+
def mark_free(self):
|
|
90
|
+
if self.status == 0:
|
|
91
|
+
raise ValidationError({'msg': 'Курьер и так свободен'})
|
|
92
|
+
self.status = 0
|
|
93
|
+
self.status_changed_at = timezone.now()
|
|
94
|
+
self.order = None
|
|
95
|
+
self.save()
|
|
96
|
+
self.hub.update_number_free_couriers()
|
|
97
|
+
|
|
98
|
+
def mark_service(self):
|
|
99
|
+
if self.status == 2:
|
|
100
|
+
raise ValidationError({'msg': 'Курьер и так в тех. обслуживании'})
|
|
101
|
+
self.status = 2
|
|
102
|
+
self.status_changed_at = timezone.now()
|
|
103
|
+
self.order = None
|
|
104
|
+
self.save()
|
|
105
|
+
self.hub.update_number_free_couriers()
|
|
106
|
+
|
|
107
|
+
def can_take_cargo(self, weight):
|
|
108
|
+
return self.max_weight >= weight
|
|
109
|
+
|
|
110
|
+
class Meta:
|
|
111
|
+
indexes = [
|
|
112
|
+
models.Index(fields=['hub']),
|
|
113
|
+
models.Index(fields=['status']),
|
|
114
|
+
models.Index(fields=['status', 'hub']),
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
def __str__(self):
|
|
118
|
+
return f'{self.courier_id} - {self.max_weight}'
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class MealOrder(models.Model):
|
|
122
|
+
title = models.CharField(unique=True, max_length=256)
|
|
123
|
+
weight = models.PositiveIntegerField()
|
|
124
|
+
user = models.ForeignKey(User, models.CASCADE)
|
|
125
|
+
courier = models.ForeignKey(Courier, models.SET_NULL, null=True, blank=True)
|
|
126
|
+
start_location = models.ForeignKey(Location, models.CASCADE, related_name='start_location_order')
|
|
127
|
+
end_location = models.ForeignKey(Location, models.CASCADE, related_name='end_location_order')
|
|
128
|
+
idempotency_key = models.CharField(unique=True, null=True, blank=True, max_length=256)
|
|
129
|
+
status = models.IntegerField(
|
|
130
|
+
choices=(
|
|
131
|
+
(0, 'created'),
|
|
132
|
+
(1, 'assigned'),
|
|
133
|
+
(2, 'delivered'),
|
|
134
|
+
(3, 'cancelled'),
|
|
135
|
+
), default=0
|
|
136
|
+
)
|
|
137
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
138
|
+
|
|
139
|
+
def clean(self):
|
|
140
|
+
if self.start_location_id == self.end_location_id:
|
|
141
|
+
raise ValidationError({'msg': 'end и start не могут совпадать'})
|
|
142
|
+
|
|
143
|
+
class Meta:
|
|
144
|
+
indexes = [
|
|
145
|
+
models.Index(fields=['weight']),
|
|
146
|
+
models.Index(fields=['user']),
|
|
147
|
+
models.Index(fields=['status', 'created_at']),
|
|
148
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from rest_framework import permissions
|
|
2
|
+
|
|
3
|
+
ADMIN, KITCHEN, STUDENT = 0, 1, 2
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _role(request):
|
|
7
|
+
user = request.user
|
|
8
|
+
return user.role if (user and user.is_authenticated) else None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NotAuth(permissions.BasePermission):
|
|
12
|
+
def has_permission(self, request, view):
|
|
13
|
+
return not request.user.is_authenticated
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AdminOnly(permissions.BasePermission):
|
|
17
|
+
def has_permission(self, request, view):
|
|
18
|
+
return _role(request) == ADMIN
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class KitchenAndAdmin(permissions.BasePermission):
|
|
22
|
+
def has_permission(self, request, view):
|
|
23
|
+
return _role(request) in (ADMIN, KITCHEN)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class GetOrAdmin(permissions.BasePermission):
|
|
27
|
+
"""Locations: GET — всем (в т.ч. гость); запись — только admin."""
|
|
28
|
+
def has_permission(self, request, view):
|
|
29
|
+
if request.method in permissions.SAFE_METHODS:
|
|
30
|
+
return True
|
|
31
|
+
return _role(request) == ADMIN
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AuthGetOrAdmin(permissions.BasePermission):
|
|
35
|
+
"""Homes: GET — авторизованным (student/kitchen/admin); запись — только admin."""
|
|
36
|
+
def has_permission(self, request, view):
|
|
37
|
+
if request.method in permissions.SAFE_METHODS:
|
|
38
|
+
return _role(request) is not None
|
|
39
|
+
return _role(request) == ADMIN
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AuthGetOrAdminAndKitchen(permissions.BasePermission):
|
|
43
|
+
"""Couriers: GET — всем (в т.ч. гость); запись — kitchen/admin."""
|
|
44
|
+
def has_permission(self, request, view):
|
|
45
|
+
if request.method in permissions.SAFE_METHODS:
|
|
46
|
+
return True
|
|
47
|
+
return _role(request) in (ADMIN, KITCHEN)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from decimal import Decimal
|
|
2
|
+
|
|
3
|
+
from rest_framework import serializers
|
|
4
|
+
|
|
5
|
+
from .models import Location, Courier, CourierHub, User, MealOrder
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LocationSerializer(serializers.ModelSerializer):
|
|
9
|
+
latitude = serializers.DecimalField(min_value=Decimal('-90'), max_value=Decimal('90'),
|
|
10
|
+
max_digits=9, decimal_places=6)
|
|
11
|
+
longitude = serializers.DecimalField(min_value=Decimal('-180'), max_value=Decimal('180'),
|
|
12
|
+
max_digits=9, decimal_places=6)
|
|
13
|
+
|
|
14
|
+
class Meta:
|
|
15
|
+
model = Location
|
|
16
|
+
fields = '__all__'
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CourierSerializer(serializers.ModelSerializer):
|
|
20
|
+
status_label = serializers.CharField(source='get_status_display', read_only=True)
|
|
21
|
+
|
|
22
|
+
class Meta:
|
|
23
|
+
model = Courier
|
|
24
|
+
fields = '__all__'
|
|
25
|
+
read_only_fields = ['courier_id', 'status', 'order', 'status_changed_at', 'created_at']
|
|
26
|
+
|
|
27
|
+
def validate(self, attrs):
|
|
28
|
+
if attrs.get('start_location') == attrs.get('end_location'):
|
|
29
|
+
raise serializers.ValidationError({'end_location': 'end и start не могут совпадать'})
|
|
30
|
+
return attrs
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MealOrderSerializer(serializers.ModelSerializer):
|
|
34
|
+
weight = serializers.IntegerField(min_value=1)
|
|
35
|
+
|
|
36
|
+
class Meta:
|
|
37
|
+
model = MealOrder
|
|
38
|
+
fields = '__all__'
|
|
39
|
+
read_only_fields = ['user', 'courier', 'idempotency_key', 'status', 'created_at']
|
|
40
|
+
|
|
41
|
+
def validate(self, attrs):
|
|
42
|
+
if attrs.get('start_location') == attrs.get('end_location'):
|
|
43
|
+
raise serializers.ValidationError({'end_location': 'end и start не могут совпадать'})
|
|
44
|
+
return attrs
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class UserSerializer(serializers.ModelSerializer):
|
|
48
|
+
class Meta:
|
|
49
|
+
model = User
|
|
50
|
+
fields = ['id', 'username', 'role', 'is_active', 'created_at']
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CourierHubSerializer(serializers.ModelSerializer):
|
|
54
|
+
class Meta:
|
|
55
|
+
model = CourierHub
|
|
56
|
+
fields = '__all__'
|
|
57
|
+
read_only_fields = ['hub_id', 'number_free_couriers', 'created_at']
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class RegisterSerializer(serializers.ModelSerializer):
|
|
61
|
+
password = serializers.CharField(write_only=True)
|
|
62
|
+
|
|
63
|
+
class Meta:
|
|
64
|
+
model = User
|
|
65
|
+
fields = ('id', 'username', 'password')
|
|
66
|
+
|
|
67
|
+
def create(self, validated_data):
|
|
68
|
+
return User.objects.create_user(role=2, **validated_data)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class LoginSerializer(serializers.Serializer):
|
|
72
|
+
username = serializers.CharField()
|
|
73
|
+
password = serializers.CharField(write_only=True)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class AllocateSerializer(serializers.Serializer):
|
|
77
|
+
weight = serializers.IntegerField(min_value=1)
|
|
78
|
+
start_id = serializers.PrimaryKeyRelatedField(queryset=Location.objects.all())
|
|
79
|
+
end_id = serializers.PrimaryKeyRelatedField(queryset=Location.objects.all())
|
|
80
|
+
|
|
81
|
+
def validate(self, attrs):
|
|
82
|
+
if attrs['start_id'] == attrs['end_id']:
|
|
83
|
+
raise serializers.ValidationError({'end_id': 'end и start не могут совпадать'})
|
|
84
|
+
return attrs
|
campuseats/app/tests.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from rest_framework.test import APIClient
|
|
3
|
+
from rest_framework.authtoken.models import Token
|
|
4
|
+
|
|
5
|
+
from campuseats.app.models import User, Location, CourierHub, Courier
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def user(role, name='u'):
|
|
9
|
+
return User.objects.create_user(username=name, password='pass12345', role=role)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def client(u=None):
|
|
13
|
+
c = APIClient()
|
|
14
|
+
if u:
|
|
15
|
+
token, _ = Token.objects.get_or_create(user=u)
|
|
16
|
+
c.credentials(HTTP_AUTHORIZATION='Bearer ' + token.key)
|
|
17
|
+
return c
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def two_locations():
|
|
21
|
+
a = Location.objects.create(title='A', latitude='55', longitude='37')
|
|
22
|
+
b = Location.objects.create(title='B', latitude='56', longitude='38')
|
|
23
|
+
return a, b
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.mark.django_db
|
|
27
|
+
def test_register_and_login():
|
|
28
|
+
c = client()
|
|
29
|
+
assert c.post('/api/users/registration/', {'username': 'a', 'password': 'pass12345'}, format='json').status_code == 201
|
|
30
|
+
r = c.post('/api/users/login/', {'username': 'a', 'password': 'pass12345'}, format='json')
|
|
31
|
+
assert r.status_code == 200 and 'token' in r.data
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.mark.django_db
|
|
35
|
+
def test_permissions():
|
|
36
|
+
assert client().get('/api/homes/').status_code == 401 # гость
|
|
37
|
+
assert client(user(2)).get('/api/users/').status_code == 403 # student
|
|
38
|
+
assert client(user(0, 'adm')).get('/api/users/').status_code == 200 # admin
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.mark.django_db
|
|
42
|
+
def test_locations_public_and_admin_write():
|
|
43
|
+
two_locations()
|
|
44
|
+
assert client().get('/api/locations/').status_code == 200
|
|
45
|
+
r = client(user(0, 'adm')).post('/api/locations/',
|
|
46
|
+
{'title': 'C', 'latitude': '1', 'longitude': '2'}, format='json')
|
|
47
|
+
assert r.status_code == 201
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.mark.django_db
|
|
51
|
+
def test_allocate_and_no_free():
|
|
52
|
+
a, b = two_locations()
|
|
53
|
+
hub = CourierHub.objects.create(name='H')
|
|
54
|
+
Courier.objects.create(max_weight=10, status=0, start_location=a, end_location=b, hub=hub)
|
|
55
|
+
hub.update_number_free_couriers()
|
|
56
|
+
c = client(user(1, 'kit'))
|
|
57
|
+
assert c.post('/api/allocate/', {'weight': 3, 'start_id': a.id, 'end_id': b.id}, format='json').status_code == 201
|
|
58
|
+
assert c.post('/api/allocate/', {'weight': 3, 'start_id': a.id, 'end_id': b.id}, format='json').status_code == 409
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.mark.django_db
|
|
62
|
+
def test_order_idempotency():
|
|
63
|
+
a, b = two_locations()
|
|
64
|
+
c = client(user(2, 'stu'))
|
|
65
|
+
body = {'title': 'O', 'weight': 3, 'start_location': a.id, 'end_location': b.id}
|
|
66
|
+
r1 = c.post('/api/orders/', body, format='json', HTTP_IDEMPOTENCY_KEY='k1')
|
|
67
|
+
r2 = c.post('/api/orders/', body, format='json', HTTP_IDEMPOTENCY_KEY='k1')
|
|
68
|
+
assert r1.status_code == 201 and r2.status_code == 200
|
campuseats/app/urls.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from django.urls import path, include
|
|
2
|
+
from campuseats.app import views
|
|
3
|
+
|
|
4
|
+
user_urls = [
|
|
5
|
+
path('', views.UserList.as_view()),
|
|
6
|
+
path('registration/', views.Register.as_view()),
|
|
7
|
+
path('login/', views.Login.as_view())
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
urlpatterns = [
|
|
11
|
+
path('users/', include(user_urls)),
|
|
12
|
+
|
|
13
|
+
path('locations/', views.LocationListCreate.as_view()),
|
|
14
|
+
path('locations/<int:pk>/', views.LocationRetrieveUpdateDestroy.as_view()),
|
|
15
|
+
|
|
16
|
+
path('homes/', views.CourierHubListCreate.as_view()),
|
|
17
|
+
path('homes/<pk>/', views.CourierHubRetrieveUpdateDestroy.as_view()),
|
|
18
|
+
|
|
19
|
+
path('couriers/', views.CourierListCreate.as_view()),
|
|
20
|
+
path('couriers/<pk>/', views.CourierRetrieveUpdateDestroy.as_view()),
|
|
21
|
+
path('couriers/<pk>/in-transit/', views.CourierInTransit.as_view()),
|
|
22
|
+
path('couriers/<pk>/free/', views.CourierFree.as_view()),
|
|
23
|
+
path('couriers/<pk>/service/', views.CourierService.as_view()),
|
|
24
|
+
|
|
25
|
+
path('allocate/', views.Allocate.as_view()),
|
|
26
|
+
path('orders/', views.MealOrderListCreate.as_view())
|
|
27
|
+
]
|
campuseats/app/views.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from django.contrib.auth import authenticate
|
|
4
|
+
from django.db import transaction
|
|
5
|
+
from rest_framework import generics, status, permissions
|
|
6
|
+
from rest_framework.exceptions import AuthenticationFailed
|
|
7
|
+
from rest_framework.generics import get_object_or_404
|
|
8
|
+
from rest_framework.response import Response
|
|
9
|
+
from rest_framework.authtoken.models import Token
|
|
10
|
+
|
|
11
|
+
from .exceptions import NoFreeCouriers
|
|
12
|
+
from .models import Location, MealOrder, Courier, CourierHub, User
|
|
13
|
+
from .serializers import (LoginSerializer, MealOrderSerializer, CourierSerializer, CourierHubSerializer,
|
|
14
|
+
RegisterSerializer, LocationSerializer, UserSerializer, AllocateSerializer)
|
|
15
|
+
from .permissions import (AdminOnly, KitchenAndAdmin, GetOrAdmin, AuthGetOrAdmin,
|
|
16
|
+
AuthGetOrAdminAndKitchen, NotAuth)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger('app')
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Register(generics.CreateAPIView):
|
|
22
|
+
queryset = User.objects.all()
|
|
23
|
+
serializer_class = RegisterSerializer
|
|
24
|
+
permission_classes = [NotAuth]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Login(generics.CreateAPIView):
|
|
28
|
+
serializer_class = LoginSerializer
|
|
29
|
+
permission_classes = [permissions.AllowAny]
|
|
30
|
+
|
|
31
|
+
def create(self, request, *args, **kwargs):
|
|
32
|
+
data = self.get_serializer(data=request.data)
|
|
33
|
+
data.is_valid(raise_exception=True)
|
|
34
|
+
user = authenticate(username=data.validated_data['username'],
|
|
35
|
+
password=data.validated_data['password'])
|
|
36
|
+
if user is None:
|
|
37
|
+
raise AuthenticationFailed('Неверный логин или пароль')
|
|
38
|
+
token, _ = Token.objects.get_or_create(user=user)
|
|
39
|
+
return Response({'id': user.id, 'username': user.username, 'token': token.key})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class UserList(generics.ListAPIView):
|
|
43
|
+
queryset = User.objects.all()
|
|
44
|
+
serializer_class = UserSerializer
|
|
45
|
+
permission_classes = [AdminOnly]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class LocationListCreate(generics.ListCreateAPIView):
|
|
49
|
+
queryset = Location.objects.all()
|
|
50
|
+
serializer_class = LocationSerializer
|
|
51
|
+
permission_classes = [GetOrAdmin]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class LocationRetrieveUpdateDestroy(generics.RetrieveUpdateDestroyAPIView):
|
|
55
|
+
queryset = Location.objects.all()
|
|
56
|
+
serializer_class = LocationSerializer
|
|
57
|
+
permission_classes = [GetOrAdmin]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CourierHubListCreate(generics.ListCreateAPIView):
|
|
61
|
+
queryset = CourierHub.objects.all()
|
|
62
|
+
serializer_class = CourierHubSerializer
|
|
63
|
+
permission_classes = [AuthGetOrAdmin]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class CourierHubRetrieveUpdateDestroy(generics.RetrieveUpdateDestroyAPIView):
|
|
67
|
+
queryset = CourierHub.objects.all()
|
|
68
|
+
serializer_class = CourierHubSerializer
|
|
69
|
+
permission_classes = [AuthGetOrAdmin]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class CourierListCreate(generics.ListCreateAPIView):
|
|
73
|
+
serializer_class = CourierSerializer
|
|
74
|
+
permission_classes = [AuthGetOrAdminAndKitchen]
|
|
75
|
+
|
|
76
|
+
def get_queryset(self):
|
|
77
|
+
qs = Courier.objects.all()
|
|
78
|
+
p = self.request.query_params
|
|
79
|
+
if p.get('status') is not None:
|
|
80
|
+
qs = qs.filter(status=p['status'])
|
|
81
|
+
if p.get('hub_id'):
|
|
82
|
+
qs = qs.filter(hub_id=p['hub_id'])
|
|
83
|
+
if p.get('min_weight'):
|
|
84
|
+
qs = qs.filter(max_weight__gte=p['min_weight'])
|
|
85
|
+
return qs
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class CourierRetrieveUpdateDestroy(generics.RetrieveUpdateDestroyAPIView):
|
|
89
|
+
queryset = Courier.objects.all()
|
|
90
|
+
serializer_class = CourierSerializer
|
|
91
|
+
permission_classes = [AuthGetOrAdminAndKitchen]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class CourierInTransit(generics.CreateAPIView):
|
|
95
|
+
queryset = Courier.objects.all()
|
|
96
|
+
permission_classes = [KitchenAndAdmin]
|
|
97
|
+
|
|
98
|
+
def post(self, request, *args, **kwargs):
|
|
99
|
+
courier = self.get_object()
|
|
100
|
+
courier.start_location = get_object_or_404(Location, pk=request.data.get('start_id'))
|
|
101
|
+
courier.end_location = get_object_or_404(Location, pk=request.data.get('end_id'))
|
|
102
|
+
courier.clean()
|
|
103
|
+
with transaction.atomic():
|
|
104
|
+
courier.mark_in_transit()
|
|
105
|
+
logger.info('courier %s in_transit', courier.courier_id)
|
|
106
|
+
return Response(CourierSerializer(courier).data)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class CourierFree(generics.CreateAPIView):
|
|
110
|
+
queryset = Courier.objects.all()
|
|
111
|
+
permission_classes = [permissions.IsAuthenticated]
|
|
112
|
+
|
|
113
|
+
def post(self, request, *args, **kwargs):
|
|
114
|
+
courier = self.get_object()
|
|
115
|
+
with transaction.atomic():
|
|
116
|
+
courier.mark_free()
|
|
117
|
+
logger.info('courier %s free', courier.courier_id)
|
|
118
|
+
return Response(CourierSerializer(courier).data)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class CourierService(generics.CreateAPIView):
|
|
122
|
+
queryset = Courier.objects.all()
|
|
123
|
+
permission_classes = [KitchenAndAdmin]
|
|
124
|
+
|
|
125
|
+
def post(self, request, *args, **kwargs):
|
|
126
|
+
courier = self.get_object()
|
|
127
|
+
with transaction.atomic():
|
|
128
|
+
courier.mark_service()
|
|
129
|
+
logger.info('courier %s service', courier.courier_id)
|
|
130
|
+
return Response(CourierSerializer(courier).data)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class Allocate(generics.CreateAPIView):
|
|
134
|
+
serializer_class = AllocateSerializer
|
|
135
|
+
permission_classes = [KitchenAndAdmin]
|
|
136
|
+
|
|
137
|
+
def create(self, request, *args, **kwargs):
|
|
138
|
+
data = self.get_serializer(data=request.data)
|
|
139
|
+
data.is_valid(raise_exception=True)
|
|
140
|
+
weight = data.validated_data['weight']
|
|
141
|
+
start = data.validated_data['start_id']
|
|
142
|
+
end = data.validated_data['end_id']
|
|
143
|
+
with transaction.atomic():
|
|
144
|
+
courier = (Courier.objects.select_for_update()
|
|
145
|
+
.filter(status=0, max_weight__gte=weight)
|
|
146
|
+
.order_by('-hub__number_free_couriers', 'status_changed_at')
|
|
147
|
+
.first())
|
|
148
|
+
if courier is None:
|
|
149
|
+
logger.info('allocate no free couriers, weight %s', weight)
|
|
150
|
+
raise NoFreeCouriers()
|
|
151
|
+
courier.start_location = start
|
|
152
|
+
courier.end_location = end
|
|
153
|
+
courier.clean()
|
|
154
|
+
courier.mark_in_transit()
|
|
155
|
+
logger.info('allocate courier %s weight %s', courier.courier_id, weight)
|
|
156
|
+
return Response(CourierSerializer(courier).data, status=status.HTTP_201_CREATED)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class MealOrderListCreate(generics.ListCreateAPIView):
|
|
160
|
+
serializer_class = MealOrderSerializer
|
|
161
|
+
permission_classes = [permissions.IsAuthenticated]
|
|
162
|
+
|
|
163
|
+
def get_queryset(self):
|
|
164
|
+
qs = MealOrder.objects.all()
|
|
165
|
+
if self.request.user.role == 2:
|
|
166
|
+
qs = qs.filter(user=self.request.user)
|
|
167
|
+
return qs
|
|
168
|
+
|
|
169
|
+
def create(self, request, *args, **kwargs):
|
|
170
|
+
key = request.headers.get('Idempotency-Key')
|
|
171
|
+
if key:
|
|
172
|
+
order = MealOrder.objects.filter(idempotency_key=key).first()
|
|
173
|
+
if order:
|
|
174
|
+
return Response(MealOrderSerializer(order).data)
|
|
175
|
+
data = self.get_serializer(data=request.data)
|
|
176
|
+
data.is_valid(raise_exception=True)
|
|
177
|
+
order = data.save(user=request.user, idempotency_key=key)
|
|
178
|
+
logger.info('order %s created', order.id)
|
|
179
|
+
return Response(data.data, status=status.HTTP_201_CREATED)
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ASGI config for backend project.
|
|
3
|
+
|
|
4
|
+
It exposes the ASGI callable as a module-level variable named ``application``.
|
|
5
|
+
|
|
6
|
+
For more information on this file, see
|
|
7
|
+
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from django.core.asgi import get_asgi_application
|
|
13
|
+
|
|
14
|
+
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'campuseats.backend.settings')
|
|
15
|
+
|
|
16
|
+
application = get_asgi_application()
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django settings for backend project.
|
|
3
|
+
|
|
4
|
+
Generated by 'django-admin startproject' using Django 6.0.6.
|
|
5
|
+
|
|
6
|
+
For more information on this file, see
|
|
7
|
+
https://docs.djangoproject.com/en/6.0/topics/settings/
|
|
8
|
+
|
|
9
|
+
For the full list of settings and their values, see
|
|
10
|
+
https://docs.djangoproject.com/en/6.0/ref/settings/
|
|
11
|
+
"""
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
|
16
|
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Quick-start development settings - unsuitable for production
|
|
20
|
+
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
|
|
21
|
+
|
|
22
|
+
# SECURITY WARNING: keep the secret key used in production secret!
|
|
23
|
+
SECRET_KEY = 'django-insecure-g%321n1fi_f=!gch&9av^bfxjzon*del25_p1g_x-xjv68j-2p'
|
|
24
|
+
|
|
25
|
+
# SECURITY WARNING: don't run with debug turned on in production!
|
|
26
|
+
DEBUG = True
|
|
27
|
+
|
|
28
|
+
ALLOWED_HOSTS = ['localhost', '127.0.0.1']
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Application definition
|
|
32
|
+
|
|
33
|
+
INSTALLED_APPS = [
|
|
34
|
+
'django.contrib.admin',
|
|
35
|
+
'django.contrib.auth',
|
|
36
|
+
'django.contrib.contenttypes',
|
|
37
|
+
'django.contrib.sessions',
|
|
38
|
+
'django.contrib.messages',
|
|
39
|
+
'django.contrib.staticfiles',
|
|
40
|
+
'rest_framework',
|
|
41
|
+
'rest_framework.authtoken',
|
|
42
|
+
'drf_spectacular',
|
|
43
|
+
|
|
44
|
+
'campuseats.app'
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
AUTH_USER_MODEL = 'app.User'
|
|
48
|
+
REST_FRAMEWORK = {
|
|
49
|
+
'DEFAULT_AUTHENTICATION_CLASSES': ['campuseats.app.authentication.BearerToken'],
|
|
50
|
+
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
|
51
|
+
'EXCEPTION_HANDLER': 'campuseats.app.exceptions.custom_exception_handler'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
MIDDLEWARE = [
|
|
55
|
+
'django.middleware.security.SecurityMiddleware',
|
|
56
|
+
'django.contrib.sessions.middleware.SessionMiddleware',
|
|
57
|
+
'django.middleware.common.CommonMiddleware',
|
|
58
|
+
'django.middleware.csrf.CsrfViewMiddleware',
|
|
59
|
+
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
|
60
|
+
'django.contrib.messages.middleware.MessageMiddleware',
|
|
61
|
+
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
ROOT_URLCONF = 'campuseats.backend.urls'
|
|
65
|
+
|
|
66
|
+
TEMPLATES = [
|
|
67
|
+
{
|
|
68
|
+
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
|
69
|
+
'DIRS': [],
|
|
70
|
+
'APP_DIRS': True,
|
|
71
|
+
'OPTIONS': {
|
|
72
|
+
'context_processors': [
|
|
73
|
+
'django.template.context_processors.request',
|
|
74
|
+
'django.contrib.auth.context_processors.auth',
|
|
75
|
+
'django.contrib.messages.context_processors.messages',
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
WSGI_APPLICATION = 'campuseats.backend.wsgi.application'
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# Database
|
|
85
|
+
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
|
86
|
+
|
|
87
|
+
DATABASES = {
|
|
88
|
+
'default': {
|
|
89
|
+
'ENGINE': 'django.db.backends.postgresql',
|
|
90
|
+
'USER': os.getenv('DB_USER'),
|
|
91
|
+
'NAME': os.getenv('DB_NAME'),
|
|
92
|
+
'PASSWORD': os.getenv('DB_PASSWORD'),
|
|
93
|
+
'HOST': 'db',
|
|
94
|
+
'PORT': '5432',
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Password validation
|
|
100
|
+
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
|
101
|
+
|
|
102
|
+
AUTH_PASSWORD_VALIDATORS = [
|
|
103
|
+
{
|
|
104
|
+
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
|
114
|
+
},
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# Internationalization
|
|
119
|
+
# https://docs.djangoproject.com/en/6.0/topics/i18n/
|
|
120
|
+
|
|
121
|
+
LANGUAGE_CODE = 'en-us'
|
|
122
|
+
|
|
123
|
+
TIME_ZONE = 'UTC'
|
|
124
|
+
|
|
125
|
+
USE_I18N = True
|
|
126
|
+
|
|
127
|
+
USE_TZ = True
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# Static files (CSS, JavaScript, Images)
|
|
131
|
+
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
|
132
|
+
|
|
133
|
+
STATIC_URL = 'static/'
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --- Локальный запуск/тесты без Postgres: SQLite, если DB_NAME не задан ---
|
|
137
|
+
if not os.getenv('DB_NAME'):
|
|
138
|
+
DATABASES = {
|
|
139
|
+
'default': {
|
|
140
|
+
'ENGINE': 'django.db.backends.sqlite3',
|
|
141
|
+
'NAME': BASE_DIR / 'db.sqlite3',
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
else:
|
|
145
|
+
DATABASES['default']['HOST'] = os.getenv('DB_HOST', 'db')
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
LOGGING = {
|
|
149
|
+
'version': 1,
|
|
150
|
+
'disable_existing_loggers': False,
|
|
151
|
+
'handlers': {'console': {'class': 'logging.StreamHandler'}},
|
|
152
|
+
'loggers': {'app': {'handlers': ['console'], 'level': 'INFO'}},
|
|
153
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
URL configuration for backend project.
|
|
3
|
+
|
|
4
|
+
The `urlpatterns` list routes URLs to views. For more information please see:
|
|
5
|
+
https://docs.djangoproject.com/en/6.0/topics/http/urls/
|
|
6
|
+
Examples:
|
|
7
|
+
Function views
|
|
8
|
+
1. Add an import: from my_app import views
|
|
9
|
+
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
|
10
|
+
Class-based views
|
|
11
|
+
1. Add an import: from other_app.views import Home
|
|
12
|
+
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
|
13
|
+
Including another URLconf
|
|
14
|
+
1. Import the include() function: from django.urls import include, path
|
|
15
|
+
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
|
16
|
+
"""
|
|
17
|
+
from django.contrib import admin
|
|
18
|
+
from django.urls import path, include
|
|
19
|
+
from drf_spectacular.views import SpectacularSwaggerView, SpectacularAPIView
|
|
20
|
+
urlpatterns = [
|
|
21
|
+
path('admin/', admin.site.urls),
|
|
22
|
+
path('api/', include('campuseats.app.urls')),
|
|
23
|
+
|
|
24
|
+
path('schema/', SpectacularAPIView.as_view(), name='schema'),
|
|
25
|
+
path('schema/swagger/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger')
|
|
26
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WSGI config for backend project.
|
|
3
|
+
|
|
4
|
+
It exposes the WSGI callable as a module-level variable named ``application``.
|
|
5
|
+
|
|
6
|
+
For more information on this file, see
|
|
7
|
+
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from django.core.wsgi import get_wsgi_application
|
|
13
|
+
|
|
14
|
+
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'campuseats.backend.settings')
|
|
15
|
+
|
|
16
|
+
application = get_wsgi_application()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: campuseats
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CampusEats — Django REST backend for a campus food-delivery service
|
|
5
|
+
Author-email: nazar <fedkedr@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://pypi.org/project/campuseats/
|
|
8
|
+
Keywords: django,rest,campus,delivery
|
|
9
|
+
Classifier: Framework :: Django
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: Django==6.0.6
|
|
16
|
+
Requires-Dist: djangorestframework==3.17.1
|
|
17
|
+
Requires-Dist: psycopg2-binary==2.9.12
|
|
18
|
+
Requires-Dist: drf-spectacular==0.28.0
|
|
19
|
+
Requires-Dist: asgiref==3.11.1
|
|
20
|
+
Requires-Dist: sqlparse==0.5.5
|
|
21
|
+
Requires-Dist: tzdata==2026.2
|
|
22
|
+
|
|
23
|
+
# CampusEats
|
|
24
|
+
|
|
25
|
+
Django REST backend for a campus food-delivery service.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install campuseats
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
After installation the `campuseats` console command runs Django management
|
|
36
|
+
commands against the bundled project settings (`campuseats.backend.settings`):
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
campuseats migrate
|
|
40
|
+
campuseats createsuperuser
|
|
41
|
+
campuseats runserver
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Configuration
|
|
45
|
+
|
|
46
|
+
Database is configured via environment variables (falls back to a local
|
|
47
|
+
SQLite file when `DB_NAME` is unset):
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
DB_NAME=campus_eats_db
|
|
51
|
+
DB_USER=campus_eats_user
|
|
52
|
+
DB_PASSWORD=campus_eats_pass
|
|
53
|
+
DB_HOST=localhost
|
|
54
|
+
DB_PORT=5432
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Package layout
|
|
58
|
+
|
|
59
|
+
- `campuseats.backend` — Django project config (settings, urls, wsgi, asgi)
|
|
60
|
+
- `campuseats.app` — application (models, views, serializers, migrations)
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
MIT
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
campuseats/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
campuseats/__main__.py,sha256=VTtT1e-tSFAMV8E7-zb_RGO2kPTyRfnhFP0BjZkfaTw,813
|
|
3
|
+
campuseats/app/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
campuseats/app/admin.py,sha256=mrfnFyywEkSvLD6Bwc2CbtTA1gg8flElhOp8i7sjKRs,1582
|
|
5
|
+
campuseats/app/apps.py,sha256=AioYNyDfJYhcXrKlwo_0kyZwJ5STqYG9v25zLxYi6RQ,149
|
|
6
|
+
campuseats/app/authentication.py,sha256=LMkxksCytAS0vjS_wxHR7Uhr1WcsBvYpBLsWz2zT3Js,132
|
|
7
|
+
campuseats/app/exceptions.py,sha256=5VcT07mz-mOpg5sp1d9uHBY8VF00uVPRiuiXBfUPD5k,1110
|
|
8
|
+
campuseats/app/models.py,sha256=4lvihExg-u7Rc9Bq7x27m7xwCWHviD4hUkctmIzEg3A,5102
|
|
9
|
+
campuseats/app/permissions.py,sha256=XvQTcBy57uBGnLzQQreQRUhxVGFa_smICwOtwgGx2Y8,1597
|
|
10
|
+
campuseats/app/serializers.py,sha256=X8jf-vt8RDUifP4GZdV4RUwTS-AnZyDcYbQfmOP1WiU,2929
|
|
11
|
+
campuseats/app/tests.py,sha256=m1DW4l1pDzyiTLTvIKQoAfiUeHrPHZDASxQh4ttO5Nc,2584
|
|
12
|
+
campuseats/app/urls.py,sha256=-yUlf2R-r6wOeYFpfYW5m77wsyOu4F1sA3Gvd4ZCVEg,999
|
|
13
|
+
campuseats/app/views.py,sha256=tUXmTui5laBNTSt_51ydWZ_sLf-EnEWwQ4IfetVydIE,6780
|
|
14
|
+
campuseats/app/migrations/0001_initial.py,sha256=mi8KQsiZ8ybevHeAq8XrA32Nuie8qfGZcotYqtxDbjQ,7501
|
|
15
|
+
campuseats/app/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
campuseats/backend/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
+
campuseats/backend/asgi.py,sha256=DyAzhRB5jr7Y-FYY7wHaz2I6hKlJ7B44PC2k8wTxplE,402
|
|
18
|
+
campuseats/backend/settings.py,sha256=yK6li5G9Z8QKmtDHl3dycjIqMcyMSggq4W-VIba2a0Q,4136
|
|
19
|
+
campuseats/backend/urls.py,sha256=xjqCTf9t3D00FpK4mbExYZ0iRVkONK-bRq08H3IRPdI,1060
|
|
20
|
+
campuseats/backend/wsgi.py,sha256=LLzP5DkNEggWhwBfO7kf9KeQY6t40yEjNsICG6wP_7M,402
|
|
21
|
+
campuseats-0.1.0.dist-info/METADATA,sha256=7G2EkG3W4EGXAIDHuHS1MWCFAIfpn9XcVxQiSHFJHN8,1634
|
|
22
|
+
campuseats-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
23
|
+
campuseats-0.1.0.dist-info/entry_points.txt,sha256=0ZvCa6whj0tqlWgu-47VECJ9TSmZAhr5vpYW5jPIV_Y,56
|
|
24
|
+
campuseats-0.1.0.dist-info/top_level.txt,sha256=JeX-zwixPr4fWy1IYDKKchDZgv-mAGEInah-FAbaWXE,11
|
|
25
|
+
campuseats-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
campuseats
|