From ddbf7986658f501edc8d785f819312b137a7a371 Mon Sep 17 00:00:00 2001 From: colaftc Date: Mon, 6 Jul 2026 23:13:57 +0800 Subject: [PATCH] feat: error_code + api doc + printing-job fields --- api_v1/management/commands/set_app_version.py | 22 ++ .../sync_external_printing_records.py | 10 +- api_v1/tasks.py | 30 +- api_v1/test_external_printing_records_sync.py | 90 ++++++ api_v1/views/printing/serializers.py | 8 + .../views/printing/test_printing_job_api.py | 6 + api_v2/test_auth_login_api.py | 264 ++++++++++++++++++ ...ternal_printing_order_snapshot_sync_api.py | 35 +++ api_v2/tests.py | 4 + api_v2/views/printing.py | 4 + celerybeat-schedule | Bin 12288 -> 12288 bytes celerybeat-schedule-shm | Bin 32768 -> 0 bytes celerybeat-schedule-wal | Bin 4120032 -> 0 bytes flower/app_version.py | 63 +++++ flower/error_code.py | 88 ++++++ flower/error_code_registry.py | 73 +++++ flower/error_code_views.py | 12 + flower/exception_handler.py | 101 +++++++ flower/settings.py | 8 + flower/test_app_version_api.py | 61 ++++ flower/urls.py | 115 +++++++- 21 files changed, 977 insertions(+), 17 deletions(-) create mode 100644 api_v1/management/commands/set_app_version.py create mode 100644 api_v2/test_auth_login_api.py delete mode 100644 celerybeat-schedule-shm delete mode 100644 celerybeat-schedule-wal create mode 100644 flower/app_version.py create mode 100644 flower/error_code.py create mode 100644 flower/error_code_registry.py create mode 100644 flower/error_code_views.py create mode 100644 flower/exception_handler.py create mode 100644 flower/test_app_version_api.py diff --git a/api_v1/management/commands/set_app_version.py b/api_v1/management/commands/set_app_version.py new file mode 100644 index 0000000..94c837f --- /dev/null +++ b/api_v1/management/commands/set_app_version.py @@ -0,0 +1,22 @@ +from django.core.management.base import BaseCommand + +from flower.app_version import set_cached_app_version_payload + + +class Command(BaseCommand): + help = "更新 App 最新版本信息缓存" + + def add_arguments(self, parser): + parser.add_argument("--major", type=int, required=True) + parser.add_argument("--minor", type=int, required=True) + parser.add_argument("--build", type=int, required=True) + parser.add_argument("--download-url", required=True) + + def handle(self, *args, **options): + payload = set_cached_app_version_payload( + major=options["major"], + minor=options["minor"], + build=options["build"], + download_url=options["download_url"], + ) + self.stdout.write(self.style.SUCCESS(str(payload))) diff --git a/api_v1/management/commands/sync_external_printing_records.py b/api_v1/management/commands/sync_external_printing_records.py index ea4ae1d..9bdb648 100644 --- a/api_v1/management/commands/sync_external_printing_records.py +++ b/api_v1/management/commands/sync_external_printing_records.py @@ -8,7 +8,15 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument('--limit', type=int, default=100) + parser.add_argument( + '--no-advance-cursor', + action='store_true', + help='只拉取并同步本批 records,不推进外部 cursor。适合本地/测试环境检查。', + ) def handle(self, *args, **options): - payload = sync_external_printing_records.run(limit=options['limit']) + payload = sync_external_printing_records.run( + limit=options['limit'], + advance_cursor=not options['no_advance_cursor'], + ) self.stdout.write(self.style.SUCCESS(str(payload))) diff --git a/api_v1/tasks.py b/api_v1/tasks.py index ff38634..630eee3 100644 --- a/api_v1/tasks.py +++ b/api_v1/tasks.py @@ -743,6 +743,10 @@ def _upload_product_image(product, image_payload: dict): product.save(update_fields=['image', 'updated_at']) +def _external_product_allows_missing_image(external_product_name: str) -> bool: + return '白布' in str(external_product_name or '').strip() + + @shared_task(bind=True) def backfill_external_product_images( self, @@ -811,23 +815,38 @@ def _ensure_external_product(*, merchant, external_product_name: str, category): if not normalized: raise RuntimeError('外部产品名为空,无法创建/绑定产品') + allows_missing_image = _external_product_allows_missing_image(normalized) existing_product = ( basic_models.Product.objects.filter(merchant=merchant, name=normalized) .order_by('id') .first() ) if existing_product: + if existing_product.image: + return existing_product, False + if allows_missing_image: + return existing_product, False + image_payload = _fetch_external_product_image(normalized) + _upload_product_image(existing_product, image_payload) return existing_product, False - image_payload = _fetch_external_product_image(normalized) product = basic_models.Product.objects.create( merchant=merchant, category=category, name=normalized, ) try: + image_payload = _fetch_external_product_image(normalized) _upload_product_image(product, image_payload) - except Exception: + except Exception as exc: + if allows_missing_image: + logger.warning( + '外部白布产品图片缺失,已创建无图 Product: product_id=%s product_name=%s error=%s', + product.id, + normalized, + exc, + ) + return product, True if product.image: try: product.image.delete(save=False) @@ -1876,14 +1895,14 @@ def sync_mdy_plate_orders_for_merchant( @shared_task(bind=True) -def sync_external_printing_records(self, limit: int = 100): +def sync_external_printing_records(self, limit: int = 100, advance_cursor: bool = True): """ 从外部 records 接口增量同步印染订单/任务。 策略: - 拉取时强制 update_cursor=false - 按外部 record.ID 处理失败并记录 - - 本批处理结束后无论是否有部分失败,都推进远端 cursor + - 默认在本批处理结束后推进远端 cursor;本地检查可传 advance_cursor=False 禁止推进 """ limit = max(1, int(limit or 100)) sync_user = _get_printing_sync_user() @@ -1908,7 +1927,7 @@ def sync_external_printing_records(self, limit: int = 100): ) cursor_payload = None - if last_record_id is not None: + if advance_cursor and last_record_id is not None: cursor_payload = _advance_external_printing_cursor(cursor_value=int(last_record_id)) else: batch_result = { @@ -1947,6 +1966,7 @@ def sync_external_printing_records(self, limit: int = 100): 'cursor_before': payload.get('cursor_before'), 'cursor_after': payload.get('cursor_after'), 'cursor_updated': bool(cursor_payload), + 'advance_cursor': bool(advance_cursor), 'group_count': batch_result['group_count'], } logger.info('外部印染 records 同步完成: %s', result) diff --git a/api_v1/test_external_printing_records_sync.py b/api_v1/test_external_printing_records_sync.py index a3d9098..fa910b3 100644 --- a/api_v1/test_external_printing_records_sync.py +++ b/api_v1/test_external_printing_records_sync.py @@ -45,6 +45,7 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase): merchant=self.merchant, category=self.category, name='Tj1712#12号色-24码', + image='product_images/existing.jpg', ) state = State.objects.create(name='待生产') @@ -232,6 +233,85 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase): self.assertEqual(failure.external_order_id, 'KD20432358') self.assertIn('外部图片不存在', failure.error) + @patch('api_v1.tasks._advance_external_printing_cursor') + @patch('api_v1.tasks._fetch_external_product_image') + @patch('api_v1.tasks._fetch_external_printing_records') + def test_sync_can_skip_advancing_external_cursor( + self, + mock_fetch_records, + mock_fetch_image, + mock_advance_cursor, + ): + mock_fetch_records.return_value = self._build_payload( + [self._build_record(record_id=1000100, product_name=self.existing_product.name)] + ) + + result = sync_external_printing_records.run(limit=100, advance_cursor=False) + + self.assertEqual(result['jobs_created'], 1) + self.assertEqual(result['failed_records'], 0) + self.assertFalse(result['cursor_updated']) + self.assertFalse(result['advance_cursor']) + mock_advance_cursor.assert_not_called() + + @patch('api_v1.tasks._advance_external_printing_cursor') + @patch('api_v1.tasks._fetch_external_product_image') + @patch('api_v1.tasks._fetch_external_printing_records') + def test_missing_white_fabric_image_does_not_block_sync_or_record_failure( + self, + mock_fetch_records, + mock_fetch_image, + mock_advance_cursor, + ): + product_name = '加厚白布-25码' + mock_fetch_records.return_value = self._build_payload( + [self._build_record(record_id=1000101, product_name=product_name)] + ) + mock_advance_cursor.return_value = {'updated': True} + mock_fetch_image.side_effect = RuntimeError(f'外部图片不存在: {product_name}') + + result = sync_external_printing_records.run(limit=100) + + self.assertEqual(result['jobs_created'], 1) + self.assertEqual(result['failed_records'], 0) + self.assertFalse(api_models.PrintingExternalSyncFailure.objects.exists()) + + product = basic_models.Product.objects.get(merchant=self.merchant, name=product_name) + self.assertFalse(product.image) + job = printing_models.PrintingJob.objects.get(original_id=1000101) + self.assertEqual(job.product, product) + + @patch('api_v1.tasks._advance_external_printing_cursor') + @patch('api_v1.tasks._fetch_external_product_image') + @patch('api_v1.tasks._fetch_external_printing_records') + def test_existing_non_white_product_without_image_still_records_failure_when_image_missing( + self, + mock_fetch_records, + mock_fetch_image, + mock_advance_cursor, + ): + product = basic_models.Product.objects.create( + merchant=self.merchant, + category=self.category, + name='Tj1712#12号色-无图', + ) + mock_fetch_records.return_value = self._build_payload( + [self._build_record(record_id=1000102, product_name=product.name)] + ) + mock_advance_cursor.return_value = {'updated': True} + mock_fetch_image.side_effect = RuntimeError(f'外部图片不存在: {product.name}') + + result = sync_external_printing_records.run(limit=100) + + self.assertEqual(result['jobs_created'], 0) + self.assertEqual(result['failed_records'], 1) + self.assertEqual(result['failed_record_ids'], [1000102]) + self.assertFalse(printing_models.PrintingJob.objects.filter(original_id=1000102).exists()) + + failure = api_models.PrintingExternalSyncFailure.objects.get(external_record_id=1000102) + self.assertEqual(failure.product_name, product.name) + self.assertIn('外部图片不存在', failure.error) + @patch('api_v1.tasks._advance_external_printing_cursor') @patch('api_v1.tasks._fetch_external_product_image') @patch('api_v1.tasks._fetch_external_printing_records') @@ -324,6 +404,16 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase): self.assertIn("'remaining_failures': 0", output.getvalue()) self.assertFalse(api_models.PrintingExternalSyncFailure.objects.exists()) + @patch('api_v1.management.commands.sync_external_printing_records.sync_external_printing_records') + def test_sync_command_supports_no_advance_cursor(self, mock_task): + mock_task.run.return_value = {'cursor_updated': False, 'advance_cursor': False} + + output = StringIO() + call_command('sync_external_printing_records', '--limit', '5', '--no-advance-cursor', stdout=output) + + mock_task.run.assert_called_once_with(limit=5, advance_cursor=False) + self.assertIn("'advance_cursor': False", output.getvalue()) + @patch('api_v1.tasks._advance_external_printing_cursor') @patch('api_v1.tasks._fetch_external_product_image') @patch('api_v1.tasks._fetch_external_printing_records') diff --git a/api_v1/views/printing/serializers.py b/api_v1/views/printing/serializers.py index c715642..6ab3ce7 100644 --- a/api_v1/views/printing/serializers.py +++ b/api_v1/views/printing/serializers.py @@ -385,6 +385,8 @@ class PrintingJobListSerializer(serializers.ModelSerializer): billed_quantity = serializers.DecimalField(max_digits=18, decimal_places=2, read_only=True) merchant_id = serializers.IntegerField(source='merchant.id', read_only=True, allow_null=True) fabric = serializers.SerializerMethodField() + curve = serializers.CharField(source='printing_order.curve', read_only=True, allow_null=True) + new_curve = serializers.CharField(source='printing_order.new_curve', read_only=True, allow_null=True) class Meta: model = models.PrintingJob @@ -400,6 +402,8 @@ class PrintingJobListSerializer(serializers.ModelSerializer): 'batch_advance_records', 'saleitems', 'fabric', + 'curve', + 'new_curve', 'created_at', 'updated_at' ] read_only_fields = [ @@ -497,6 +501,8 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer): billed_quantity = serializers.DecimalField(max_digits=18, decimal_places=2, read_only=True) merchant_id = serializers.IntegerField(source='merchant.id', read_only=True, allow_null=True) fabric = serializers.SerializerMethodField() + curve = serializers.CharField(source='printing_order.curve', read_only=True, allow_null=True) + new_curve = serializers.CharField(source='printing_order.new_curve', read_only=True, allow_null=True) class Meta: model = models.PrintingJob @@ -511,6 +517,8 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer): 'batch_advance_records', 'saleitems', 'fabric', + 'curve', + 'new_curve', 'created_at', 'updated_at' ] read_only_fields = [ diff --git a/api_v1/views/printing/test_printing_job_api.py b/api_v1/views/printing/test_printing_job_api.py index fe4da3b..d3f4e7e 100644 --- a/api_v1/views/printing/test_printing_job_api.py +++ b/api_v1/views/printing/test_printing_job_api.py @@ -77,6 +77,8 @@ class PrintingJobAPITestCase(TestCase): customer=self.customer, fabric='测试布料', width='150cm', + curve='测试曲线', + new_curve='测试新曲线', process=self.process, ) @@ -191,6 +193,8 @@ class PrintingJobAPITestCase(TestCase): for item in response.data['results']: self.assertIn('customer_name', item) self.assertEqual(item['customer_name'], self.customer.name) + self.assertEqual(item['curve'], '测试曲线') + self.assertEqual(item['new_curve'], '测试新曲线') self.assertIn('batch_advance_records', item) self.assertIsInstance(item['batch_advance_records'], list) self.assertEqual(len(item['batch_advance_records']), 0) @@ -944,6 +948,8 @@ class PrintingJobAPITestCase(TestCase): detail = self.client.get(f'/api/v1/printing-jobs/{job.id}/') self.assertEqual(detail.status_code, status.HTTP_200_OK) self.assertEqual(detail.data['external_order_id'], 'KD20410611') + self.assertEqual(detail.data['curve'], '测试曲线') + self.assertEqual(detail.data['new_curve'], '测试新曲线') def test_filter_by_quantity_range(self): """测试按数量范围过滤""" diff --git a/api_v2/test_auth_login_api.py b/api_v2/test_auth_login_api.py new file mode 100644 index 0000000..29f902c --- /dev/null +++ b/api_v2/test_auth_login_api.py @@ -0,0 +1,264 @@ +from django.contrib.auth.models import User +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import RefreshToken + +from basic_info.models import Employee, EmployeeStatusEnum, Merchant, MerchantTypeEnum +from flower.error_code import AuthErrorCode + + +class AuthLoginAPITestCase(TestCase): + def setUp(self): + self.url = '/api/auth/login/' + self.client = APIClient() + self.merchant = Merchant.objects.create(name='登录商户', type=MerchantTypeEnum.FACTORY) + + def _create_user_with_employee(self, *, username='login_user', password='pass12345', is_active=True, + employee_status=EmployeeStatusEnum.ACTIVE): + user = User.objects.create_user(username=username, password=password, is_active=is_active) + Employee.objects.create( + merchant=self.merchant, + sys_user=user, + name=f'{username}员工', + status=employee_status, + ) + return user + + def assert_login_error(self, response, *, http_status, error_code, code, message): + self.assertEqual(response.status_code, http_status) + self.assertEqual(response.data['error_code'], int(error_code)) + self.assertEqual(response.data['code'], code) + self.assertEqual(response.data['message'], message) + self.assertEqual(response.data['detail'], message) + + def test_login_success(self): + self._create_user_with_employee(username='success_user', password='pass12345') + + response = self.client.post( + self.url, + {'username': 'success_user', 'password': 'pass12345'}, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('access', response.data) + self.assertIn('refresh', response.data) + + def test_login_requires_username_and_password(self): + response = self.client.post(self.url, {}, format='json') + + self.assert_login_error( + response, + http_status=status.HTTP_400_BAD_REQUEST, + error_code=AuthErrorCode.MISSING_CREDENTIALS, + code='missing_credentials', + message='请输入用户名和密码', + ) + + def test_login_requires_username(self): + response = self.client.post(self.url, {'password': 'pass12345'}, format='json') + + self.assert_login_error( + response, + http_status=status.HTTP_400_BAD_REQUEST, + error_code=AuthErrorCode.MISSING_USERNAME, + code='missing_username', + message='请输入用户名', + ) + + def test_login_requires_password(self): + response = self.client.post(self.url, {'username': 'login_user'}, format='json') + + self.assert_login_error( + response, + http_status=status.HTTP_400_BAD_REQUEST, + error_code=AuthErrorCode.MISSING_PASSWORD, + code='missing_password', + message='请输入密码', + ) + + def test_login_rejects_unknown_user(self): + response = self.client.post( + self.url, + {'username': 'missing_user', 'password': 'pass12345'}, + format='json', + ) + + self.assert_login_error( + response, + http_status=status.HTTP_401_UNAUTHORIZED, + error_code=AuthErrorCode.USER_NOT_FOUND, + code='user_not_found', + message='用户不存在', + ) + + def test_login_rejects_invalid_password(self): + self._create_user_with_employee(username='wrong_password_user', password='pass12345') + + response = self.client.post( + self.url, + {'username': 'wrong_password_user', 'password': 'bad-password'}, + format='json', + ) + + self.assert_login_error( + response, + http_status=status.HTTP_401_UNAUTHORIZED, + error_code=AuthErrorCode.INVALID_PASSWORD, + code='invalid_password', + message='密码错误', + ) + + def test_login_rejects_inactive_user(self): + self._create_user_with_employee(username='inactive_user', password='pass12345', is_active=False) + + response = self.client.post( + self.url, + {'username': 'inactive_user', 'password': 'pass12345'}, + format='json', + ) + + self.assert_login_error( + response, + http_status=status.HTTP_403_FORBIDDEN, + error_code=AuthErrorCode.USER_INACTIVE, + code='user_inactive', + message='该用户已被禁用', + ) + + def test_login_rejects_user_without_employee(self): + User.objects.create_user(username='no_employee_user', password='pass12345') + + response = self.client.post( + self.url, + {'username': 'no_employee_user', 'password': 'pass12345'}, + format='json', + ) + + self.assert_login_error( + response, + http_status=status.HTTP_403_FORBIDDEN, + error_code=AuthErrorCode.EMPLOYEE_NOT_BOUND, + code='employee_not_bound', + message='该用户未绑定员工身份', + ) + + def test_login_rejects_inactive_employee(self): + self._create_user_with_employee( + username='inactive_employee_user', + password='pass12345', + employee_status=EmployeeStatusEnum.INACTIVE, + ) + + response = self.client.post( + self.url, + {'username': 'inactive_employee_user', 'password': 'pass12345'}, + format='json', + ) + + self.assert_login_error( + response, + http_status=status.HTTP_403_FORBIDDEN, + error_code=AuthErrorCode.EMPLOYEE_INACTIVE, + code='employee_inactive', + message='该员工已离职或停用', + ) + + +class JwtAuthenticationFailureAPITestCase(TestCase): + def setUp(self): + self.url = '/api/v2/me/visible-pages/' + self.client = APIClient() + + def assert_auth_error(self, response, *, http_status, error_code, code, message): + self.assertEqual(response.status_code, http_status) + self.assertEqual(response.data['error_code'], int(error_code)) + self.assertEqual(response.data['code'], code) + self.assertEqual(response.data['message'], message) + self.assertEqual(response.data['detail'], message) + + def test_protected_api_without_token_returns_structured_error(self): + response = self.client.get(self.url) + + self.assert_auth_error( + response, + http_status=status.HTTP_401_UNAUTHORIZED, + error_code=AuthErrorCode.NOT_AUTHENTICATED, + code='not_authenticated', + message='未提供认证凭据', + ) + + def test_protected_api_bad_authorization_header_returns_structured_error(self): + response = self.client.get(self.url, HTTP_AUTHORIZATION='Bearer token extra') + + self.assert_auth_error( + response, + http_status=status.HTTP_401_UNAUTHORIZED, + error_code=AuthErrorCode.BAD_AUTHORIZATION_HEADER, + code='bad_authorization_header', + message='Authorization 请求头格式错误', + ) + + def test_protected_api_invalid_token_returns_structured_error(self): + response = self.client.get(self.url, HTTP_AUTHORIZATION='Bearer invalid-token') + + self.assert_auth_error( + response, + http_status=status.HTTP_401_UNAUTHORIZED, + error_code=AuthErrorCode.TOKEN_NOT_VALID, + code='token_not_valid', + message='Token 无效或已过期', + ) + self.assertIn('messages', response.data) + + def test_protected_api_deleted_token_user_returns_structured_error(self): + user = User.objects.create_user(username='deleted_token_user', password='pass12345') + token = str(RefreshToken.for_user(user).access_token) + user.delete() + + response = self.client.get(self.url, HTTP_AUTHORIZATION=f'Bearer {token}') + + self.assert_auth_error( + response, + http_status=status.HTTP_401_UNAUTHORIZED, + error_code=AuthErrorCode.TOKEN_USER_NOT_FOUND, + code='token_user_not_found', + message='Token 对应用户不存在', + ) + + def test_protected_api_inactive_token_user_returns_structured_error(self): + user = User.objects.create_user(username='inactive_token_user', password='pass12345', is_active=False) + token = str(RefreshToken.for_user(user).access_token) + + response = self.client.get(self.url, HTTP_AUTHORIZATION=f'Bearer {token}') + + self.assert_auth_error( + response, + http_status=status.HTTP_401_UNAUTHORIZED, + error_code=AuthErrorCode.TOKEN_USER_INACTIVE, + code='token_user_inactive', + message='Token 对应用户已被禁用', + ) + + +class ErrorCodeListAPITestCase(TestCase): + def setUp(self): + self.url = '/api/error-codes/' + self.client = APIClient() + + def test_error_code_list_is_public_and_includes_auth_codes(self): + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + auth_group = next(group for group in response.data['modules'] if group['module'] == 'auth') + self.assertEqual(auth_group['title'], '认证') + + codes = {item['error_code']: item for item in auth_group['codes']} + self.assertEqual(codes[10001]['name'], 'MISSING_CREDENTIALS') + self.assertEqual(codes[10001]['code'], 'missing_credentials') + self.assertEqual(codes[10001]['message'], '请输入用户名和密码') + self.assertEqual(codes[10014]['name'], 'PERMISSION_DENIED') + self.assertEqual(codes[10014]['code'], 'permission_denied') + self.assertEqual(codes[10014]['message'], '无权限访问该资源') + self.assertEqual(set(codes.keys()), {int(item) for item in AuthErrorCode}) diff --git a/api_v2/test_external_printing_order_snapshot_sync_api.py b/api_v2/test_external_printing_order_snapshot_sync_api.py index 0f4b923..fd22531 100644 --- a/api_v2/test_external_printing_order_snapshot_sync_api.py +++ b/api_v2/test_external_printing_order_snapshot_sync_api.py @@ -46,11 +46,13 @@ class PrintingOrderExternalSnapshotSyncAPITest(TestCase): merchant=self.merchant, category=self.category, name='Tj1712#12号色-24码', + image='product_images/keep.jpg', ) self.product_stale = basic_models.Product.objects.create( merchant=self.merchant, category=self.category, name='Tj1712#12号色-25码', + image='product_images/stale.jpg', ) state = stateflow_models.State.objects.create(name='待生产') @@ -233,6 +235,39 @@ class PrintingOrderExternalSnapshotSyncAPITest(TestCase): self.assertEqual(len(audit.before_snapshot['printing_jobs']), 2) self.assertIn('sub_id', audit.before_snapshot['printing_jobs'][0]) + @patch('api_v1.tasks._fetch_external_product_image') + @patch('api_v1.tasks._fetch_external_printing_order_snapshot') + def test_sync_allows_missing_image_for_white_fabric_product(self, mock_fetch_snapshot, mock_fetch_image): + product_name = '外部白布-快照款' + mock_fetch_snapshot.return_value = self._build_snapshot_payload( + records=[ + self._build_record( + record_id=1000018, + product_name=product_name, + quantity='12.00', + ) + ] + ) + mock_fetch_image.side_effect = RuntimeError(f'外部图片不存在: {product_name}') + + resp = self.client.post( + '/api/v2/printing-orders/sync-external-snapshot/', + {'external_order_id': 'KD20432358'}, + format='json', + ) + + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data['jobs_created'], 1) + + product = basic_models.Product.objects.get(merchant=self.merchant, name=product_name) + self.assertFalse(product.image) + job = printing_models.PrintingJob.objects.get(original_id=1000018) + self.assertEqual(job.product, product) + + audit = printing_models.PrintingOrderExternalSnapshotSyncAudit.objects.get(id=resp.data['audit_id']) + self.assertTrue(audit.is_success) + self.assertEqual(audit.failure_reason, '') + @patch('api_v1.tasks._fetch_external_printing_order_snapshot') def test_sync_records_audit_when_external_snapshot_not_found(self, mock_fetch_snapshot): mock_fetch_snapshot.side_effect = ExternalPrintingOrderSnapshotNotFoundError('未找到该 external_order_id 对应的订单') diff --git a/api_v2/tests.py b/api_v2/tests.py index 4dcdd7f..d0b6ffe 100644 --- a/api_v2/tests.py +++ b/api_v2/tests.py @@ -171,6 +171,8 @@ class PrintingJobByCustomerAPITest(TestCase): customer=self.customer, fabric='棉', width='150cm', + curve='曲线A', + new_curve='新曲线A', external_order_id='KD20410611', ) self.printing_order_other = printing_models.PrintingOrder.objects.create( @@ -256,6 +258,8 @@ class PrintingJobByCustomerAPITest(TestCase): self.assertEqual(resp.data[0]['external_order_id'], 'KD20410611') self.assertEqual(resp.data[0]['sub_id'], 11) self.assertEqual(resp.data[0]['billed_quantity'], '20.00') + self.assertEqual(resp.data[0]['curve'], '曲线A') + self.assertEqual(resp.data[0]['new_curve'], '新曲线A') def test_filter_by_printing_order(self): resp = self._get({ diff --git a/api_v2/views/printing.py b/api_v2/views/printing.py index 8236834..4c6abe1 100644 --- a/api_v2/views/printing.py +++ b/api_v2/views/printing.py @@ -55,6 +55,8 @@ class PrintingJobV2Serializer(serializers.ModelSerializer): business_object_id = serializers.SerializerMethodField() width = serializers.SerializerMethodField() fabric = serializers.SerializerMethodField() + curve = serializers.CharField(source='printing_order.curve', read_only=True, allow_null=True) + new_curve = serializers.CharField(source='printing_order.new_curve', read_only=True, allow_null=True) product = ProductSerializer(read_only=True) class Meta: @@ -70,6 +72,8 @@ class PrintingJobV2Serializer(serializers.ModelSerializer): 'quantity', 'width', 'fabric', + 'curve', + 'new_curve', 'unit', 'size', 'pieces', diff --git a/celerybeat-schedule b/celerybeat-schedule index 04f010c567a0d7abbebed709bb3dd738fe3da037..44706ac79e07ba7a812577f1571448b93dd41dfc 100644 GIT binary patch delta 110 zcmV-!0FnQIV1Qt-AszzpXOkly-3SK;3JN&_=Q@)JA6o$jlVl%10`X;&r5_^~@ni!c z6m@iCUuAA#Vr*q(fCY#yA@O6g-yiu8C^+N?0}ujqdN|_@0~!KlZgg^KWpg;^5Cb9t QUte}*a&u{KZeL$G<>@LVivR!s delta 110 zcmZojXh_(gD97|Jda|CB8B1{yheh~%J_&IR@Hrn&-#{-v zzfJDJk#7I;`M(#BIz_Mh`ych^{-3U>i|Bo=n}fXv_8nLbSPobYSPobYSPobY{C{)c z&;NmS6EFc2FaZ-V0TVC*6EFc2FaZ-V0TVC*6EFc2FaZ-V0TVC*6EFc2FaZ-V0TVC* z6EFc2FaZ-V0TVC*6EFc2FaZ-V0TVC*6EFc2FaZ-V0TcK?2rz2miW6cYHsT^a5+V_j zz!k}n0;!M&>EMox$c(JWj-2p79^^v-6hvVZMR9nc6w06+Dxeaopc-nR7Q9gh_0Rx~ z&=^h894*lrZP6Yb(HULQ9X-(-ebFBSF&INJ93wFrV=)fnF%gq771J>jvoQyAF&_)D z7)!AnE3q1Du^t<-8C$U(JFy#k5rhLcgd;eH6F7x4IEM?kge$m;>$r&!+`&COz#}}y z6FkEUyuus2!v}oA7lh+GejpORK|1Kfn2gQ1jL(Ej#3XcOa;9J^reQj|Gb1xIE3-2v zJ(!32Sbzmtm_=EfUM$5jEXNA0#44=D8mvWc)?qz1U?VnWQ#NNywq{$lXGeBsS9WJl z_GVx9=RgkTP!8uvj^$OpvwN=}-Q@gcSK{}vAI-+Abp;J1ebGo2Q zx}vMPuA2(c9o^FdJeb6U;QMkVAhax4!fHPtt4&osJT#y(^kqmA~ ziPT7o^vHlr$bxLhfn3OqyvPqv6haXcLkW~bX_Q5IR77P|MRn9fZTO%r>Z2ii(FD!V z0xVVK??*FZN+S z4&WdT;V_QiD2|~Y{W*b?IEB+VgR>aGK+fX=F5(g{;|i|g8m{98ZsHbh;|}iP9`56Q z9^_#j<#C?mX`bbIUgTv4^BQmP7H{(|@ADx;8OEo4&X;`6w|vi!{LHWX#t8o8FG5lb zIV+apD4r6?MTwPE$>gS#O0Bd?uMEnhEXt-F%B9@OtNij*Ar(#g4Fqdx1az9~XK^-I462OV-a95EbDj_B!JRwiHqCSU?4 zU;-v!0w!PrCSU?4U;-v!0w!PrCSU?4U;-v!0w!PrCSU?4U;-v!0w!PrCSU?4U;-v! Y0w!PrCSU?4U;-v!0w!Pr|22WX0OoPDW&i*H diff --git a/celerybeat-schedule-wal b/celerybeat-schedule-wal deleted file mode 100644 index c562b6fdc46086c1722806e529001e85e809bfbb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4120032 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 int: + return max(0, int(value or 0)) + + +def build_app_version_payload(*, major, minor, build, download_url: str) -> dict: + return { + "latest_version": { + "major": _coerce_version_part(major), + "minor": _coerce_version_part(minor), + "build": _coerce_version_part(build), + }, + "download_url": str(download_url or "").strip(), + } + + +def get_app_version_payload() -> dict: + cached_payload = cache.get(APP_VERSION_CACHE_KEY) + if isinstance(cached_payload, dict): + try: + version = cached_payload["latest_version"] + return build_app_version_payload( + major=version["major"], + minor=version["minor"], + build=version["build"], + download_url=cached_payload.get("download_url", ""), + ) + except (KeyError, TypeError, ValueError): + pass + + return build_app_version_payload( + major=getattr(settings, "APP_LATEST_VERSION_MAJOR", 0), + minor=getattr(settings, "APP_LATEST_VERSION_MINOR", 0), + build=getattr(settings, "APP_LATEST_VERSION_BUILD", 0), + download_url=getattr(settings, "APP_DOWNLOAD_URL", ""), + ) + + +def set_cached_app_version_payload(*, major, minor, build, download_url: str) -> dict: + payload = build_app_version_payload( + major=major, + minor=minor, + build=build, + download_url=download_url, + ) + cache.set(APP_VERSION_CACHE_KEY, payload, timeout=None) + return payload + + +class AppVersionView(APIView): + permission_classes = [AllowAny] + + def get(self, request): + return Response(get_app_version_payload()) diff --git a/flower/error_code.py b/flower/error_code.py new file mode 100644 index 0000000..9f33fcd --- /dev/null +++ b/flower/error_code.py @@ -0,0 +1,88 @@ +from enum import IntEnum + + +class AuthErrorCode(IntEnum): + MISSING_CREDENTIALS = 10001 + MISSING_USERNAME = 10002 + MISSING_PASSWORD = 10003 + USER_NOT_FOUND = 10004 + INVALID_PASSWORD = 10005 + USER_INACTIVE = 10006 + EMPLOYEE_NOT_BOUND = 10007 + EMPLOYEE_INACTIVE = 10008 + NOT_AUTHENTICATED = 10009 + BAD_AUTHORIZATION_HEADER = 10010 + TOKEN_NOT_VALID = 10011 + TOKEN_USER_NOT_FOUND = 10012 + TOKEN_USER_INACTIVE = 10013 + PERMISSION_DENIED = 10014 + + +AUTH_ERROR_CODE_DETAILS = { + AuthErrorCode.MISSING_CREDENTIALS: { + 'code': 'missing_credentials', + 'message': '请输入用户名和密码', + }, + AuthErrorCode.MISSING_USERNAME: { + 'code': 'missing_username', + 'message': '请输入用户名', + }, + AuthErrorCode.MISSING_PASSWORD: { + 'code': 'missing_password', + 'message': '请输入密码', + }, + AuthErrorCode.USER_NOT_FOUND: { + 'code': 'user_not_found', + 'message': '用户不存在', + }, + AuthErrorCode.INVALID_PASSWORD: { + 'code': 'invalid_password', + 'message': '密码错误', + }, + AuthErrorCode.USER_INACTIVE: { + 'code': 'user_inactive', + 'message': '该用户已被禁用', + }, + AuthErrorCode.EMPLOYEE_NOT_BOUND: { + 'code': 'employee_not_bound', + 'message': '该用户未绑定员工身份', + }, + AuthErrorCode.EMPLOYEE_INACTIVE: { + 'code': 'employee_inactive', + 'message': '该员工已离职或停用', + }, + AuthErrorCode.NOT_AUTHENTICATED: { + 'code': 'not_authenticated', + 'message': '未提供认证凭据', + }, + AuthErrorCode.BAD_AUTHORIZATION_HEADER: { + 'code': 'bad_authorization_header', + 'message': 'Authorization 请求头格式错误', + }, + AuthErrorCode.TOKEN_NOT_VALID: { + 'code': 'token_not_valid', + 'message': 'Token 无效或已过期', + }, + AuthErrorCode.TOKEN_USER_NOT_FOUND: { + 'code': 'token_user_not_found', + 'message': 'Token 对应用户不存在', + }, + AuthErrorCode.TOKEN_USER_INACTIVE: { + 'code': 'token_user_inactive', + 'message': 'Token 对应用户已被禁用', + }, + AuthErrorCode.PERMISSION_DENIED: { + 'code': 'permission_denied', + 'message': '无权限访问该资源', + }, +} + + +ERROR_CODE_GROUPS = [ + { + 'module': 'auth', + 'title': '认证', + 'enum': AuthErrorCode, + 'details': AUTH_ERROR_CODE_DETAILS, + }, +] diff --git a/flower/error_code_registry.py b/flower/error_code_registry.py new file mode 100644 index 0000000..8000216 --- /dev/null +++ b/flower/error_code_registry.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from importlib import import_module +from threading import Lock + +from django.apps import apps + + +_CACHE = None +_CACHE_LOCK = Lock() + + +def list_error_code_groups(): + global _CACHE + if _CACHE is None: + with _CACHE_LOCK: + if _CACHE is None: + _CACHE = _discover_error_code_groups() + return _CACHE + + +def clear_error_code_cache(): + global _CACHE + with _CACHE_LOCK: + _CACHE = None + + +def _discover_error_code_groups(): + modules = ['flower'] + modules.extend(config.name for config in apps.get_app_configs()) + + groups = [] + seen_modules = set() + for module_name in modules: + if module_name in seen_modules: + continue + seen_modules.add(module_name) + error_code_module = _import_error_code_module(module_name) + if error_code_module is None: + continue + for group in getattr(error_code_module, 'ERROR_CODE_GROUPS', []): + groups.append(_serialize_group(group)) + + return groups + + +def _import_error_code_module(module_name: str): + error_code_module_name = f'{module_name}.error_code' + try: + return import_module(error_code_module_name) + except ModuleNotFoundError as exc: + if exc.name == error_code_module_name: + return None + raise + + +def _serialize_group(group: dict): + enum_cls = group['enum'] + details = group.get('details') or {} + return { + 'module': group['module'], + 'title': group.get('title') or group['module'], + 'codes': [_serialize_code(member, details.get(member, {})) for member in enum_cls], + } + + +def _serialize_code(member, detail: dict): + return { + 'error_code': int(member), + 'name': member.name, + 'code': detail.get('code', member.name.lower()), + 'message': detail.get('message', ''), + } diff --git a/flower/error_code_views.py b/flower/error_code_views.py new file mode 100644 index 0000000..a127494 --- /dev/null +++ b/flower/error_code_views.py @@ -0,0 +1,12 @@ +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +from flower.error_code_registry import list_error_code_groups + + +class ErrorCodeListView(APIView): + permission_classes = [AllowAny] + + def get(self, request): + return Response({'modules': list_error_code_groups()}) diff --git a/flower/exception_handler.py b/flower/exception_handler.py new file mode 100644 index 0000000..fdaec1f --- /dev/null +++ b/flower/exception_handler.py @@ -0,0 +1,101 @@ +from rest_framework import exceptions, status +from rest_framework.response import Response +from rest_framework.views import exception_handler +from rest_framework_simplejwt.exceptions import InvalidToken + +from flower.error_code import AuthErrorCode + + +def auth_error_response(*, error_code: AuthErrorCode, code: str, message: str, http_status: int, extra=None): + data = { + 'error_code': int(error_code), + 'code': code, + 'message': message, + 'detail': message, + } + if extra: + data.update(extra) + return Response(data, status=http_status) + + +def _stringify_detail(detail): + if hasattr(detail, 'code'): + return str(detail), str(detail.code) + return str(detail), None + + +def _extract_exception_detail(exc): + detail = getattr(exc, 'detail', None) + if isinstance(detail, dict): + raw_detail = detail.get('detail') + raw_code = detail.get('code') + messages = detail.get('messages') + message = str(raw_detail) if raw_detail is not None else '' + code = str(raw_code) if raw_code is not None else None + extra = {'messages': messages} if messages is not None else None + return message, code, extra + + message, code = _stringify_detail(detail) + return message, code, None + + +def custom_exception_handler(exc, context): + if isinstance(exc, InvalidToken): + _, _, extra = _extract_exception_detail(exc) + return auth_error_response( + error_code=AuthErrorCode.TOKEN_NOT_VALID, + code='token_not_valid', + message='Token 无效或已过期', + http_status=status.HTTP_401_UNAUTHORIZED, + extra=extra, + ) + + if isinstance(exc, exceptions.NotAuthenticated): + return auth_error_response( + error_code=AuthErrorCode.NOT_AUTHENTICATED, + code='not_authenticated', + message='未提供认证凭据', + http_status=status.HTTP_401_UNAUTHORIZED, + ) + + if isinstance(exc, exceptions.AuthenticationFailed): + message, code, extra = _extract_exception_detail(exc) + if code == 'bad_authorization_header': + return auth_error_response( + error_code=AuthErrorCode.BAD_AUTHORIZATION_HEADER, + code='bad_authorization_header', + message='Authorization 请求头格式错误', + http_status=status.HTTP_401_UNAUTHORIZED, + ) + if code == 'user_not_found': + return auth_error_response( + error_code=AuthErrorCode.TOKEN_USER_NOT_FOUND, + code='token_user_not_found', + message='Token 对应用户不存在', + http_status=status.HTTP_401_UNAUTHORIZED, + ) + if code == 'user_inactive': + return auth_error_response( + error_code=AuthErrorCode.TOKEN_USER_INACTIVE, + code='token_user_inactive', + message='Token 对应用户已被禁用', + http_status=status.HTTP_401_UNAUTHORIZED, + ) + + return auth_error_response( + error_code=AuthErrorCode.TOKEN_NOT_VALID, + code=code or 'authentication_failed', + message=message or '认证失败', + http_status=status.HTTP_401_UNAUTHORIZED, + extra=extra, + ) + + if isinstance(exc, exceptions.PermissionDenied): + return auth_error_response( + error_code=AuthErrorCode.PERMISSION_DENIED, + code='permission_denied', + message='无权限访问该资源', + http_status=status.HTTP_403_FORBIDDEN, + ) + + return exception_handler(exc, context) diff --git a/flower/settings.py b/flower/settings.py index e695d7e..cf2333c 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -111,6 +111,13 @@ HAOBUYE_FINANCE_SYNC_OPERATOR_ID = env.int('HAOBUYE_FINANCE_SYNC_OPERATOR_ID', d # - standard_decimal_2: 原系统口径,行金额=ROUND_HALF_UP(real_quantity * price, 2),收/付款金额保留两位 BUSINESS_AMOUNT_MODE = env('BUSINESS_AMOUNT_MODE', default='haobuye_integer_round_half_up') +# App version check API +# 默认由环境变量提供;如需不重启动态更新,可用管理命令写入缓存。 +APP_LATEST_VERSION_MAJOR = env.int('APP_LATEST_VERSION_MAJOR', default=0) +APP_LATEST_VERSION_MINOR = env.int('APP_LATEST_VERSION_MINOR', default=0) +APP_LATEST_VERSION_BUILD = env.int('APP_LATEST_VERSION_BUILD', default=0) +APP_DOWNLOAD_URL = env('APP_DOWNLOAD_URL', default='') + # 定时财务同步客户列表(临时需求,直接写死不走 env) FINANCE_SYNC_CUSTOMER_NAMES: list[str] = [ '曾念', '紫琪', '胡肖宇', '歌斯拉-胜利星厂', '胡鼎', @@ -204,6 +211,7 @@ REST_FRAMEWORK = { 'rest_framework.permissions.IsAuthenticated', ], 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'EXCEPTION_HANDLER': 'flower.exception_handler.custom_exception_handler', } # JWT 配置 diff --git a/flower/test_app_version_api.py b/flower/test_app_version_api.py new file mode 100644 index 0000000..74bc235 --- /dev/null +++ b/flower/test_app_version_api.py @@ -0,0 +1,61 @@ +from io import StringIO + +from django.core.cache import cache +from django.core.management import call_command +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + +from flower.app_version import APP_VERSION_CACHE_KEY + + +class AppVersionAPITest(TestCase): + def setUp(self): + cache.delete(APP_VERSION_CACHE_KEY) + self.client = APIClient() + + def tearDown(self): + cache.delete(APP_VERSION_CACHE_KEY) + + @override_settings( + APP_LATEST_VERSION_MAJOR=1, + APP_LATEST_VERSION_MINOR=2, + APP_LATEST_VERSION_BUILD=345, + APP_DOWNLOAD_URL="https://example.com/app.apk", + ) + def test_app_version_api_returns_settings_payload_without_auth(self): + response = self.client.get("/api/app-version/") + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data, + { + "latest_version": { + "major": 1, + "minor": 2, + "build": 345, + }, + "download_url": "https://example.com/app.apk", + }, + ) + + def test_set_app_version_command_updates_cached_api_payload(self): + output = StringIO() + call_command( + "set_app_version", + "--major", + "2", + "--minor", + "5", + "--build", + "1001", + "--download-url", + "https://example.com/latest.apk", + stdout=output, + ) + + response = self.client.get("/api/app-version/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["latest_version"], {"major": 2, "minor": 5, "build": 1001}) + self.assertEqual(response.data["download_url"], "https://example.com/latest.apk") + self.assertIn("'build': 1001", output.getvalue()) diff --git a/flower/urls.py b/flower/urls.py index 530ef30..d00fda5 100644 --- a/flower/urls.py +++ b/flower/urls.py @@ -15,44 +15,137 @@ Including another URLconf 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin +from django.contrib.auth import get_user_model +from django.contrib.auth.models import update_last_login +from django.core.exceptions import ObjectDoesNotExist from django.urls import path, include +from rest_framework import status from rest_framework.response import Response from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView from sse.views import create_sse_event, push_test_event, get_sse_status, shutdown_sse +from rest_framework_simplejwt.settings import api_settings +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer from rest_framework_simplejwt.views import ( TokenObtainPairView, # TokenRefreshView, ) +from basic_info.models import EmployeeStatusEnum +from flower.app_version import AppVersionView +from flower.error_code import AuthErrorCode +from flower.error_code_views import ErrorCodeListView + # 自定义后台站点标题(simpleui 也会读取) admin.site.site_header = "宇问科技" admin.site.site_title = "宇问科技" admin.site.index_title = "管理后台" +def login_error_response(error_code: AuthErrorCode, code: str, message: str, http_status: int): + return Response( + { + 'error_code': int(error_code), + 'code': code, + 'message': message, + 'detail': message, + }, + status=http_status, + ) + + class CustomTokenObtainPairView(TokenObtainPairView): """自定义登录视图""" def post(self, request, *args, **kwargs): + data = request.data or {} + username = (data.get('username') or '').strip() + password = data.get('password') + + if not username and not password: + return login_error_response( + AuthErrorCode.MISSING_CREDENTIALS, + 'missing_credentials', + '请输入用户名和密码', + status.HTTP_400_BAD_REQUEST, + ) + if not username: + return login_error_response( + AuthErrorCode.MISSING_USERNAME, + 'missing_username', + '请输入用户名', + status.HTTP_400_BAD_REQUEST, + ) + if not password: + return login_error_response( + AuthErrorCode.MISSING_PASSWORD, + 'missing_password', + '请输入密码', + status.HTTP_400_BAD_REQUEST, + ) + + UserModel = get_user_model() try: - resp = super().post(request, *args, **kwargs) - if resp.status_code == 200: - srz = self.get_serializer(data=request.data) - srz.is_valid() - user = srz.user + user = UserModel._default_manager.get_by_natural_key(username) + except UserModel.DoesNotExist: + return login_error_response( + AuthErrorCode.USER_NOT_FOUND, + 'user_not_found', + '用户不存在', + status.HTTP_401_UNAUTHORIZED, + ) - if not hasattr(user, 'employee'): - # 非员工用户,直接返回登录失败 - return Response({'detail': '无绑定的员工身份'}, status=401) + if not user.check_password(password): + return login_error_response( + AuthErrorCode.INVALID_PASSWORD, + 'invalid_password', + '密码错误', + status.HTTP_401_UNAUTHORIZED, + ) - return resp - except Exception as e: - return Response({'detail': '无法登录'}, status=400) + if not user.is_active: + return login_error_response( + AuthErrorCode.USER_INACTIVE, + 'user_inactive', + '该用户已被禁用', + status.HTTP_403_FORBIDDEN, + ) + + try: + employee = user.employee + except ObjectDoesNotExist: + return login_error_response( + AuthErrorCode.EMPLOYEE_NOT_BOUND, + 'employee_not_bound', + '该用户未绑定员工身份', + status.HTTP_403_FORBIDDEN, + ) + + if employee.status != EmployeeStatusEnum.ACTIVE: + return login_error_response( + AuthErrorCode.EMPLOYEE_INACTIVE, + 'employee_inactive', + '该员工已离职或停用', + status.HTTP_403_FORBIDDEN, + ) + + refresh = TokenObtainPairSerializer.get_token(user) + if api_settings.UPDATE_LAST_LOGIN: + update_last_login(None, user) + + return Response( + { + 'refresh': str(refresh), + 'access': str(refresh.access_token), + }, + status=status.HTTP_200_OK, + ) urlpatterns = [ # JWT 登录 path('api/auth/login/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'), # path('api/auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path('api/error-codes/', ErrorCodeListView.as_view(), name='error_code_list'), + path('api/app-version/', AppVersionView.as_view(), name='app_version'), # API 文档 path('api/schema/', SpectacularAPIView.as_view(), name='schema'),