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 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
@@ -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,6 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class AppConfig(AppConfig):
5
+ default_auto_field = 'django.db.models.BigAutoField'
6
+ name = 'campuseats.app'
@@ -0,0 +1,5 @@
1
+ from rest_framework.authentication import TokenAuthentication
2
+
3
+
4
+ class BearerToken(TokenAuthentication):
5
+ keyword = "Bearer"
@@ -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
@@ -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
@@ -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
+ ]
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ campuseats = campuseats.__main__:main
@@ -0,0 +1 @@
1
+ campuseats