Initial commit - Zhangmenu Django project
A menu management application built with Django, featuring: - Dish management with cover images - Order system with party dates and participants - REST API with Django REST Framework - Bootstrap-styled frontend Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MenuConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'menu'
|
||||
@@ -0,0 +1,64 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-21 13:39
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DishCategory',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=50, unique=True, verbose_name='分类名称')),
|
||||
('icon', models.CharField(blank=True, max_length=50, verbose_name='图标类名')),
|
||||
('order', models.IntegerField(default=0, verbose_name='排序')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '菜品分类',
|
||||
'verbose_name_plural': '菜品分类',
|
||||
'ordering': ['order', 'id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Dish',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, verbose_name='菜品名称')),
|
||||
('cover_image', models.ImageField(blank=True, null=True, upload_to='dishes/', verbose_name='封面图')),
|
||||
('description', models.TextField(blank=True, verbose_name='做法描述(Markdown格式)')),
|
||||
('ingredients', models.JSONField(default=list, verbose_name='材料清单')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dishes', to='menu.dishcategory', verbose_name='分类')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '菜品',
|
||||
'verbose_name_plural': '菜品',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Order',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, verbose_name='订单名称')),
|
||||
('status', models.CharField(choices=[('in_progress', '进行中'), ('completed', '已完成')], default='in_progress', max_length=20, verbose_name='状态')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='完成时间')),
|
||||
('note', models.TextField(blank=True, verbose_name='备注')),
|
||||
('dishes', models.ManyToManyField(related_name='orders', to='menu.dish', verbose_name='菜品')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '订单',
|
||||
'verbose_name_plural': '订单',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-21 15:03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('menu', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='dish',
|
||||
name='cover_image',
|
||||
field=models.URLField(blank=True, null=True, verbose_name='封面图URL'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-21 15:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('menu', '0002_alter_dish_cover_image'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='dish',
|
||||
name='cover_image',
|
||||
field=models.CharField(blank=True, max_length=500, null=True, verbose_name='封面图URL'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-22 03:36
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('menu', '0003_alter_dish_cover_image'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='dish',
|
||||
name='view_count',
|
||||
field=models.IntegerField(default=0, verbose_name='浏览次数'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-22 14:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('menu', '0004_add_view_count'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='participants',
|
||||
field=models.JSONField(default=list, verbose_name='参与人员'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='party_date',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='聚会时间'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.11 on 2026-03-05 14:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('menu', '0005_order_participants_order_party_date'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='images',
|
||||
field=models.JSONField(default=list, verbose_name='聚会照片'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,77 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class DishCategory(models.Model):
|
||||
"""菜品分类"""
|
||||
name = models.CharField(max_length=50, unique=True, verbose_name="分类名称")
|
||||
icon = models.CharField(max_length=50, blank=True, verbose_name="图标类名")
|
||||
order = models.IntegerField(default=0, verbose_name="排序")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "菜品分类"
|
||||
verbose_name_plural = "菜品分类"
|
||||
ordering = ['order', 'id']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Dish(models.Model):
|
||||
"""菜品"""
|
||||
name = models.CharField(max_length=100, verbose_name="菜品名称")
|
||||
category = models.ForeignKey(DishCategory, on_delete=models.SET_NULL, null=True, blank=True, related_name='dishes', verbose_name="分类")
|
||||
cover_image = models.CharField(max_length=500, blank=True, null=True, verbose_name="封面图URL")
|
||||
description = models.TextField(blank=True, verbose_name="做法描述(Markdown格式)")
|
||||
ingredients = models.JSONField(default=list, verbose_name="材料清单")
|
||||
view_count = models.IntegerField(default=0, verbose_name="浏览次数")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "菜品"
|
||||
verbose_name_plural = "菜品"
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Order(models.Model):
|
||||
"""订单"""
|
||||
STATUS_CHOICES = [
|
||||
('in_progress', '进行中'),
|
||||
('completed', '已完成'),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=100, verbose_name="订单名称")
|
||||
dishes = models.ManyToManyField(Dish, related_name='orders', verbose_name="菜品")
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='in_progress', verbose_name="状态")
|
||||
party_date = models.DateTimeField(null=True, blank=True, verbose_name="聚会时间")
|
||||
participants = models.JSONField(default=list, verbose_name="参与人员")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
||||
completed_at = models.DateTimeField(null=True, blank=True, verbose_name="完成时间")
|
||||
note = models.TextField(blank=True, verbose_name="备注")
|
||||
images = models.JSONField(default=list, verbose_name="聚会照片")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "订单"
|
||||
verbose_name_plural = "订单"
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_ingredients_summary(self):
|
||||
"""汇总所有菜品需要的材料"""
|
||||
summary = {}
|
||||
for dish in self.dishes.all():
|
||||
for ing in dish.ingredients:
|
||||
name = ing.get('name', '').strip()
|
||||
amount = ing.get('amount', '').strip()
|
||||
if name:
|
||||
if name in summary:
|
||||
if amount:
|
||||
summary[name] += f" + {amount}"
|
||||
else:
|
||||
summary[name] = amount
|
||||
return [{'name': k, 'amount': v} for k, v in summary.items()]
|
||||
@@ -0,0 +1,70 @@
|
||||
from django.db.models import Count
|
||||
from rest_framework import serializers
|
||||
from .models import Dish, DishCategory, Order
|
||||
|
||||
|
||||
class DishCategorySerializer(serializers.ModelSerializer):
|
||||
"""分类序列化器"""
|
||||
dish_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = DishCategory
|
||||
fields = ['id', 'name', 'icon', 'order', 'dish_count']
|
||||
|
||||
|
||||
class DishSerializer(serializers.ModelSerializer):
|
||||
"""菜品序列化器"""
|
||||
category_name = serializers.CharField(source='category.name', read_only=True)
|
||||
category_id = serializers.IntegerField(write_only=True, required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = Dish
|
||||
fields = ['id', 'name', 'category', 'category_name', 'category_id',
|
||||
'cover_image', 'description', 'ingredients', 'view_count',
|
||||
'created_at', 'updated_at']
|
||||
read_only_fields = ['created_at', 'updated_at', 'view_count']
|
||||
|
||||
|
||||
class OrderSerializer(serializers.ModelSerializer):
|
||||
"""订单序列化器"""
|
||||
dishes_detail = DishSerializer(source='dishes', many=True, read_only=True)
|
||||
ingredients_summary = serializers.SerializerMethodField()
|
||||
dish_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
write_only=True,
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ['id', 'name', 'dishes', 'dishes_detail', 'dish_ids',
|
||||
'status', 'party_date', 'participants',
|
||||
'created_at', 'completed_at', 'note', 'ingredients_summary', 'images']
|
||||
read_only_fields = ['created_at', 'completed_at']
|
||||
extra_kwargs = {
|
||||
'dishes': {'required': False}
|
||||
}
|
||||
|
||||
def get_ingredients_summary(self, obj):
|
||||
return obj.get_ingredients_summary()
|
||||
|
||||
def validate_dishes(self, value):
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
dish_ids = validated_data.pop('dish_ids', [])
|
||||
order = Order.objects.create(**validated_data)
|
||||
if dish_ids:
|
||||
dishes = Dish.objects.filter(id__in=dish_ids)
|
||||
order.dishes.set(dishes)
|
||||
return order
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
dish_ids = validated_data.pop('dish_ids', None)
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
if dish_ids is not None:
|
||||
dishes = Dish.objects.filter(id__in=dish_ids)
|
||||
instance.dishes.set(dishes)
|
||||
return instance
|
||||
@@ -0,0 +1,5 @@
|
||||
from .dish_service import DishService
|
||||
from .order_service import OrderService
|
||||
from .parser_service import ParserService
|
||||
|
||||
__all__ = ['DishService', 'OrderService', 'ParserService']
|
||||
@@ -0,0 +1,25 @@
|
||||
from django.db.models import F
|
||||
from menu.models import Dish
|
||||
|
||||
|
||||
class DishService:
|
||||
"""菜品业务逻辑"""
|
||||
|
||||
@staticmethod
|
||||
def get_queryset(category_id=None, search=None):
|
||||
"""获取菜品查询集"""
|
||||
queryset = Dish.objects.select_related('category')
|
||||
|
||||
if category_id:
|
||||
queryset = queryset.filter(category_id=category_id)
|
||||
if search:
|
||||
queryset = queryset.filter(name__icontains=search)
|
||||
|
||||
return queryset
|
||||
|
||||
@staticmethod
|
||||
def increment_view_count(dish):
|
||||
"""增加浏览次数"""
|
||||
Dish.objects.filter(pk=dish.pk).update(view_count=F('view_count') + 1)
|
||||
dish.refresh_from_db(fields=['view_count'])
|
||||
return dish
|
||||
@@ -0,0 +1,32 @@
|
||||
from django.utils import timezone
|
||||
|
||||
from menu.models import Order
|
||||
|
||||
|
||||
class OrderService:
|
||||
"""订单业务逻辑"""
|
||||
|
||||
@staticmethod
|
||||
def get_queryset(status_filter=None):
|
||||
"""获取订单查询集"""
|
||||
queryset = Order.objects.prefetch_related('dishes', 'dishes__category')
|
||||
if status_filter:
|
||||
queryset = queryset.filter(status=status_filter)
|
||||
return queryset
|
||||
|
||||
@staticmethod
|
||||
def create_order(serializer, dish_ids):
|
||||
"""创建订单并绑定菜品"""
|
||||
if dish_ids is None:
|
||||
dish_ids = []
|
||||
|
||||
order = serializer.save(dish_ids=dish_ids)
|
||||
return Order.objects.prefetch_related('dishes', 'dishes__category').get(pk=order.pk)
|
||||
|
||||
@staticmethod
|
||||
def complete_order(order):
|
||||
"""完成订单"""
|
||||
order.status = 'completed'
|
||||
order.completed_at = timezone.now()
|
||||
order.save()
|
||||
return order
|
||||
@@ -0,0 +1,217 @@
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from django.conf import settings
|
||||
import re
|
||||
import json
|
||||
|
||||
|
||||
class ParserService:
|
||||
"""URL解析业务逻辑"""
|
||||
|
||||
@staticmethod
|
||||
def parse_url(url):
|
||||
"""解析外部URL获取菜品信息"""
|
||||
if not url:
|
||||
raise ValueError('请提供URL')
|
||||
|
||||
parsed_url = urlparse(url)
|
||||
hostname = parsed_url.hostname
|
||||
allowed_hosts = getattr(settings, 'ALLOWED_RECIPE_HOSTS', set())
|
||||
|
||||
if parsed_url.scheme not in {'http', 'https'}:
|
||||
raise ValueError('URL协议不受支持')
|
||||
if not hostname:
|
||||
raise ValueError('URL缺少主机名')
|
||||
if hostname not in allowed_hosts:
|
||||
raise ValueError('该站点不在允许的解析范围内')
|
||||
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
}
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
response.encoding = 'utf-8'
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
|
||||
result = {
|
||||
'name': '',
|
||||
'cover_image': '',
|
||||
'description': '',
|
||||
'ingredients': []
|
||||
}
|
||||
|
||||
# 根据不同网站选择解析方法
|
||||
if hostname in {'xiachufang.com', 'www.xiachufang.com'}:
|
||||
result = ParserService._parse_xiachufang(soup, url)
|
||||
elif hostname in {'meishij.net', 'www.meishij.net'}:
|
||||
result = ParserService._parse_meishij(soup)
|
||||
elif hostname in {'caipuw.com', 'www.caipuw.com'}:
|
||||
result = ParserService._parse_caipuw(soup)
|
||||
else:
|
||||
result = ParserService._parse_generic(soup)
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _parse_xiachufang(soup, url):
|
||||
"""解析下厨房网站"""
|
||||
result = {
|
||||
'name': '',
|
||||
'cover_image': '',
|
||||
'description': '',
|
||||
'ingredients': []
|
||||
}
|
||||
|
||||
# 尝试从JSON-LD获取数据
|
||||
json_ld = soup.find_all('script', type='application/ld+json')
|
||||
for script in json_ld:
|
||||
try:
|
||||
data = json.loads(script.string)
|
||||
if data.get('@type') == 'Recipe':
|
||||
result['name'] = data.get('name', '')
|
||||
result['cover_image'] = data.get('image', '')
|
||||
if isinstance(result['cover_image'], list):
|
||||
result['cover_image'] = result['cover_image'][0]
|
||||
|
||||
# 解析材料
|
||||
for ing in data.get('recipeIngredient', []):
|
||||
match = re.match(r'^([\d.]+\s*[克g个勺片块大中小]+)?\s*(.+)$', ing)
|
||||
if match and match.group(2):
|
||||
amount, name = match.groups()
|
||||
result['ingredients'].append({
|
||||
'name': name.strip(),
|
||||
'amount': (amount.strip() + '克') if amount and not ('克' in amount or '个' in amount or '勺' in amount or '片' in amount or '块' in amount) else (amount.strip() if amount else '')
|
||||
})
|
||||
else:
|
||||
result['ingredients'].append({'name': ing, 'amount': ''})
|
||||
|
||||
# 解析做法
|
||||
steps = []
|
||||
recipe_steps = data.get('recipeInstructions', [])
|
||||
if isinstance(recipe_steps, list):
|
||||
for step in recipe_steps:
|
||||
text = step.get('text', '') if isinstance(step, dict) else str(step)
|
||||
if text:
|
||||
steps.append(text)
|
||||
elif isinstance(recipe_steps, str):
|
||||
parts = re.split(r'(?=\d+\.)', recipe_steps)
|
||||
for part in parts:
|
||||
part = re.sub(r'^\d+\.\s*', '', part.strip())
|
||||
if part:
|
||||
steps.append(part)
|
||||
|
||||
if steps:
|
||||
result['description'] = '\n\n'.join([f'{i+1}. {s}' for i, s in enumerate(steps)])
|
||||
break
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
continue
|
||||
|
||||
# 回退到HTML解析
|
||||
if not result['name']:
|
||||
title = soup.select_one('h1.title')
|
||||
if title:
|
||||
result['name'] = title.get_text(strip=True)
|
||||
|
||||
if not result['cover_image']:
|
||||
cover = soup.select_one('div.cover img') or soup.select_one('div.image img')
|
||||
if cover:
|
||||
result['cover_image'] = cover.get('src', '')
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _parse_meishij(soup):
|
||||
"""解析美食杰网站"""
|
||||
result = {
|
||||
'name': '',
|
||||
'cover_image': '',
|
||||
'description': '',
|
||||
'ingredients': []
|
||||
}
|
||||
|
||||
title = soup.select_one('h1.recipe_title')
|
||||
if title:
|
||||
result['name'] = title.get_text(strip=True)
|
||||
|
||||
cover = soup.select_one('div.recipe_img img')
|
||||
if cover:
|
||||
result['cover_image'] = cover.get('src', '')
|
||||
|
||||
# 材料
|
||||
ing_list = soup.select('div.yl li')
|
||||
for ing in ing_list:
|
||||
text = ing.get_text(strip=True)
|
||||
if text:
|
||||
parts = text.split()
|
||||
if len(parts) >= 2:
|
||||
result['ingredients'].append({'name': parts[0], 'amount': ' '.join(parts[1:])})
|
||||
elif len(parts) == 1:
|
||||
result['ingredients'].append({'name': parts[0], 'amount': ''})
|
||||
|
||||
# 做法
|
||||
steps = []
|
||||
step_list = soup.select('div.step_content')
|
||||
for step in step_list:
|
||||
text = step.get_text(strip=True)
|
||||
if text:
|
||||
steps.append(text)
|
||||
result['description'] = '\n\n'.join([f'{i+1}. {s}' for i, s in enumerate(steps)])
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _parse_caipuw(soup):
|
||||
"""解析菜谱大全网站"""
|
||||
result = {
|
||||
'name': '',
|
||||
'cover_image': '',
|
||||
'description': '',
|
||||
'ingredients': []
|
||||
}
|
||||
|
||||
title = soup.select_one('h1.title')
|
||||
if title:
|
||||
result['name'] = title.get_text(strip=True)
|
||||
|
||||
cover = soup.select_one('div.image img')
|
||||
if cover:
|
||||
result['cover_image'] = cover.get('src', '')
|
||||
|
||||
# 材料
|
||||
ing_list = soup.select('div.ingredients li') or soup.select('ul.ingredients li')
|
||||
for ing in ing_list:
|
||||
text = ing.get_text(strip=True)
|
||||
if text:
|
||||
result['ingredients'].append({'name': text, 'amount': ''})
|
||||
|
||||
# 做法
|
||||
steps = []
|
||||
step_list = soup.select('div.steps li') or soup.select('div.step')
|
||||
for step in step_list:
|
||||
text = step.get_text(strip=True)
|
||||
if text:
|
||||
steps.append(text)
|
||||
result['description'] = '\n\n'.join([f'{i+1}. {s}' for i, s in enumerate(steps)])
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _parse_generic(soup):
|
||||
"""通用解析"""
|
||||
result = {
|
||||
'name': '',
|
||||
'cover_image': '',
|
||||
'description': '',
|
||||
'ingredients': []
|
||||
}
|
||||
|
||||
title = soup.select_one('h1') or soup.select_one('title')
|
||||
if title:
|
||||
result['name'] = title.get_text(strip=True)[:100]
|
||||
|
||||
img = soup.select_one('article img') or soup.select_one('div.content img') or soup.select_one('img')
|
||||
if img:
|
||||
result['cover_image'] = img.get('src', '')
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,96 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from menu.models import Dish, DishCategory, Order
|
||||
|
||||
|
||||
TEST_MEDIA_ROOT = Path(settings.BASE_DIR) / 'test_media'
|
||||
|
||||
|
||||
@override_settings(MEDIA_ROOT=TEST_MEDIA_ROOT)
|
||||
class UploadImageTests(TestCase):
|
||||
def tearDown(self):
|
||||
if TEST_MEDIA_ROOT.exists():
|
||||
for path in sorted(TEST_MEDIA_ROOT.rglob('*'), reverse=True):
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
elif path.is_dir():
|
||||
path.rmdir()
|
||||
|
||||
def test_upload_rejects_invalid_extension(self):
|
||||
image = SimpleUploadedFile('bad.txt', b'file-content', content_type='image/png')
|
||||
|
||||
response = self.client.post(reverse('upload'), {'image': image})
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()['error'], 'Unsupported image extension')
|
||||
|
||||
def test_upload_rejects_invalid_content_type(self):
|
||||
image = SimpleUploadedFile('dish.png', b'file-content', content_type='text/plain')
|
||||
|
||||
response = self.client.post(reverse('upload'), {'image': image})
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()['error'], 'Unsupported image content type')
|
||||
|
||||
@override_settings(MAX_UPLOAD_IMAGE_SIZE=4)
|
||||
def test_upload_rejects_oversized_file(self):
|
||||
image = SimpleUploadedFile('dish.png', b'12345', content_type='image/png')
|
||||
|
||||
response = self.client.post(reverse('upload'), {'image': image})
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()['error'], 'Image file too large')
|
||||
|
||||
def test_upload_returns_absolute_media_url(self):
|
||||
image = SimpleUploadedFile('dish.png', b'png-bytes', content_type='image/png')
|
||||
|
||||
response = self.client.post(reverse('upload'), {'image': image}, secure=True)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertTrue(data['url'].startswith('https://testserver/media/dishes/'))
|
||||
|
||||
|
||||
class ParseUrlTests(TestCase):
|
||||
def test_parse_url_rejects_non_allowlisted_host(self):
|
||||
response = self.client.post(
|
||||
'/api/parse-url/',
|
||||
data=json.dumps({'url': 'http://127.0.0.1/internal'}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()['error'], '该站点不在允许的解析范围内')
|
||||
|
||||
|
||||
class OrderApiTests(TestCase):
|
||||
def setUp(self):
|
||||
category = DishCategory.objects.create(name='热菜')
|
||||
self.dish1 = Dish.objects.create(name='宫保鸡丁', category=category, ingredients=[{'name': '鸡肉', 'amount': '300g'}])
|
||||
self.dish2 = Dish.objects.create(name='鱼香肉丝', category=category, ingredients=[{'name': '猪肉', 'amount': '200g'}])
|
||||
|
||||
def test_create_order_binds_dish_ids_and_returns_saved_object(self):
|
||||
payload = {
|
||||
'name': '周末聚会',
|
||||
'dish_ids': [self.dish1.id, self.dish2.id],
|
||||
'participants': ['张三', '李四'],
|
||||
'party_date': '2026-03-10T12:30:00Z',
|
||||
}
|
||||
|
||||
response = self.client.post('/api/orders/', payload, content_type='application/json')
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
data = response.json()
|
||||
self.assertEqual(data['name'], '周末聚会')
|
||||
self.assertEqual(sorted(d['id'] for d in data['dishes_detail']), sorted([self.dish1.id, self.dish2.id]))
|
||||
self.assertEqual(data['participants'], ['张三', '李四'])
|
||||
|
||||
order = Order.objects.get(pk=data['id'])
|
||||
self.assertEqual(order.dishes.count(), 2)
|
||||
self.assertEqual(sorted(order.dishes.values_list('id', flat=True)), sorted([self.dish1.id, self.dish2.id]))
|
||||
@@ -0,0 +1,15 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from . import views
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'categories', views.DishCategoryViewSet)
|
||||
router.register(r'dishes', views.DishViewSet)
|
||||
router.register(r'orders', views.OrderViewSet)
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.index, name='index'),
|
||||
path('api/', include(router.urls)),
|
||||
path('api/parse-url/', views.ParseUrlView.as_view()),
|
||||
path('api/upload/', views.upload_image, name='upload'),
|
||||
]
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import render
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from menu.models import Dish, DishCategory, Order
|
||||
from menu.serializers import DishSerializer, DishCategorySerializer, OrderSerializer
|
||||
from menu.services import DishService, OrderService, ParserService
|
||||
|
||||
|
||||
def index(request):
|
||||
"""首页"""
|
||||
return render(request, 'index.html')
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
def upload_image(request):
|
||||
"""上传图片"""
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'error': 'Method not allowed'}, status=405)
|
||||
|
||||
image = request.FILES.get('image')
|
||||
if not image:
|
||||
return JsonResponse({'error': 'No image provided'}, status=400)
|
||||
|
||||
ext = Path(image.name).suffix.lower()
|
||||
if ext not in settings.ALLOWED_UPLOAD_IMAGE_EXTENSIONS:
|
||||
return JsonResponse({'error': 'Unsupported image extension'}, status=400)
|
||||
|
||||
content_type = (image.content_type or '').lower()
|
||||
if content_type not in settings.ALLOWED_UPLOAD_IMAGE_CONTENT_TYPES:
|
||||
return JsonResponse({'error': 'Unsupported image content type'}, status=400)
|
||||
|
||||
if image.size > settings.MAX_UPLOAD_IMAGE_SIZE:
|
||||
return JsonResponse({'error': 'Image file too large'}, status=400)
|
||||
|
||||
filename = f"dishes/{uuid.uuid4().hex}{ext}"
|
||||
saved_path = default_storage.save(filename, image)
|
||||
media_path = str(saved_path).lstrip('/')
|
||||
media_url = urljoin(settings.MEDIA_URL, media_path)
|
||||
|
||||
return JsonResponse({'url': request.build_absolute_uri(media_url)})
|
||||
|
||||
|
||||
class DishCategoryViewSet(viewsets.ModelViewSet):
|
||||
"""分类视图"""
|
||||
queryset = DishCategory.objects.all()
|
||||
serializer_class = DishCategorySerializer
|
||||
|
||||
|
||||
class DishViewSet(viewsets.ModelViewSet):
|
||||
"""菜品视图"""
|
||||
queryset = Dish.objects.all()
|
||||
serializer_class = DishSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
category_id = self.request.query_params.get('category')
|
||||
search = self.request.query_params.get('search')
|
||||
return DishService.get_queryset(category_id, search)
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
DishService.increment_view_count(instance)
|
||||
serializer = self.get_serializer(instance)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class OrderViewSet(viewsets.ModelViewSet):
|
||||
"""订单视图"""
|
||||
queryset = Order.objects.all()
|
||||
serializer_class = OrderSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
status_filter = self.request.query_params.get('status')
|
||||
return OrderService.get_queryset(status_filter)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
request_data = request.data.copy()
|
||||
dish_ids = request_data.pop('dish_ids', [])
|
||||
serializer = self.get_serializer(data=request_data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
order = OrderService.create_order(serializer, dish_ids)
|
||||
response_serializer = self.get_serializer(order)
|
||||
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def complete(self, request, pk=None):
|
||||
order = self.get_object()
|
||||
order = OrderService.complete_order(order)
|
||||
serializer = self.get_serializer(order)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def share(self, request, pk=None):
|
||||
order = self.get_object()
|
||||
serializer = self.get_serializer(order)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class ParseUrlView(APIView):
|
||||
"""解析外部链接"""
|
||||
|
||||
def post(self, request):
|
||||
url = request.data.get('url', '')
|
||||
try:
|
||||
result = ParserService.parse_url(url)
|
||||
return Response(result)
|
||||
except ValueError as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as e:
|
||||
return Response({'error': f'解析失败: {str(e)}'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
Reference in New Issue
Block a user