Files
school-meal/backend/meals/views.py
T
root 65b52b052f 营养校准、后台美化、静态走对象存储 CDN、标题可编辑、admin 拼音搜索
- 菜品营养按食物成分表+烹饪方式校准(calibrate_nutrition 命令)
- 后台接入 jazzmin 主题美化,移除 Google Fonts(模板覆盖)
- 前端/后台静态资源指向 qnycdn 对象存储 CDN(vite base + DJANGO_STATIC_URL)
- 菜单标题学校名称/周数可编辑,日期随周数计算
- admin 搜索支持拼音首字母/全拼(Dish.pinyin 索引,pypinyin)
- Dockerfile 使用清华 pip 镜像源
2026-08-06 21:11:14 +08:00

435 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import random
from datetime import date, timedelta
from django.http import HttpResponse
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from rest_framework import status, viewsets
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response
from .models import Dish
from .serializers import DishSerializer
WEEKDAYS = ['周一', '周二', '周三', '周四', '周五']
SCHOOL_NAME = '上海外国语大学附属宝山双语学校'
COLOR_HEADER = PatternFill('solid', fgColor='2F5597')
COLOR_DAY = PatternFill('solid', fgColor='D9E2F3')
COLOR_MEAT = PatternFill('solid', fgColor='FCE4D6')
COLOR_VEG = PatternFill('solid', fgColor='E2EFDA')
COLOR_STAPLE = PatternFill('solid', fgColor='FDE9D9')
COLOR_SOUP = PatternFill('solid', fgColor='DDEBF7')
THIN = Side(style='thin', color='808080')
BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
CENTER = Alignment(horizontal='center', vertical='center', wrap_text=True)
class DishViewSet(viewsets.ModelViewSet):
queryset = Dish.objects.all()
serializer_class = DishSerializer
def get_queryset(self):
qs = Dish.objects.all()
dish_type = self.request.query_params.get('type')
if dish_type:
qs = qs.filter(dish_type=dish_type)
return qs
TYPE_LABEL_MAP = {'荤菜': 'meat', '素菜': 'veg', '汤': 'soup', '主食': 'staple'}
@api_view(['POST'])
@permission_classes([IsAdminUser])
def bulk_create_dishes(request):
items = request.data.get('dishes')
if not isinstance(items, list) or not items:
return Response({'detail': '请提供 dishes 列表'}, status=status.HTTP_400_BAD_REQUEST)
created, skipped = [], []
for raw in items:
if not isinstance(raw, dict):
continue
name = str(raw.get('name', '')).strip()
dish_type = str(raw.get('dish_type', '')).strip()
dish_type = TYPE_LABEL_MAP.get(dish_type, dish_type)
if not name or dish_type not in ('meat', 'veg', 'soup', 'staple'):
skipped.append(name or '未命名')
continue
defaults = {
'dish_type': dish_type,
'cuisine': str(raw.get('cuisine', '家常菜') or '家常菜'),
'ingredient_detail': str(raw.get('ingredient_detail', '') or raw.get('ingredient', '')),
'protein': float(raw.get('protein', 0) or 0),
'fat': float(raw.get('fat', 0) or 0),
'calorie': int(raw.get('calorie', 0) or 0),
'is_active': bool(raw.get('is_active', True)),
}
dish, was_created = Dish.objects.update_or_create(name=name, defaults=defaults)
(created if was_created else skipped).append(name)
return Response({
'detail': f'新增 {len(created)} 道,更新/跳过 {len(skipped)} 道',
'created': created,
'skipped': skipped,
})
def _pick_unique(pool, count):
picked = random.sample(pool, min(count, len(pool)))
remaining = [d for d in pool if d not in picked]
return picked, remaining
def _pop_next(pool, shuffle_src):
if not pool:
random.shuffle(shuffle_src)
pool = shuffle_src[:]
return pool.pop(0), pool
def _serialize_dish(d):
return {
'id': d.id, 'name': d.name, 'ingredient': d.ingredient_detail, 'dish_type': d.dish_type,
'cuisine': d.cuisine, 'protein': float(d.protein), 'fat': float(d.fat), 'calorie': d.calorie,
}
def _generate_partial_week(payload, meats, vegs, soups, staples, menu_key):
pools = {'meat': meats[:], 'veg': vegs[:], 'soup': soups[:], 'staple': staples[:]}
src_pools = {'meat': meats, 'veg': vegs, 'soup': soups, 'staple': staples}
for p in pools.values():
random.shuffle(p)
day_selected = {}
for s in payload.get('selections') or []:
if s.get('menu', 'a') != menu_key:
continue
day = s.get('day')
slot = s.get('slot')
if day not in WEEKDAYS or slot not in ('meats', 'vegs', 'soup', 'staple'):
continue
dish = Dish.objects.filter(pk=s.get('dish_id'), is_active=True).first() if s.get('dish_id') else None
if not dish:
continue
key = f'{slot}{s.get("idx", 0)}' if slot in ('meats', 'vegs') else slot
day_selected.setdefault(day, {})[key] = dish
def fill(dish_type, used_ids):
pool = pools[dish_type]
while pool:
d = pool.pop(0)
if d.id not in used_ids:
return d
random.shuffle(src_pools[dish_type])
pool = pools[dish_type] = src_pools[dish_type][:]
while pool:
d = pool.pop(0)
if d.id not in used_ids:
return d
return None
week = []
for day in WEEKDAYS:
sel = day_selected.get(day, {})
used_ids = {d.id for d in sel.values()}
m0 = sel.get('meats0') or fill('meat', used_ids)
m1 = sel.get('meats1') or fill('meat', used_ids)
v0 = sel.get('vegs0') or fill('veg', used_ids)
v1 = sel.get('vegs1') or fill('veg', used_ids)
soup = sel.get('soup') or fill('soup', used_ids)
staple = sel.get('staple') or fill('staple', used_ids)
week.append({
'day': day,
'meats': [_serialize_dish(d) for d in (m0, m1) if d],
'vegs': [_serialize_dish(d) for d in (v0, v1) if d],
'soup': _serialize_dish(soup) if soup else None,
'staple': _serialize_dish(staple) if staple else None,
})
return week
def generate_week_menu(payload, menu_key='a'):
auto_all = payload.get('auto_all', True)
veg_per_day = 2 # 每天固定 2 个素菜
with_soup = bool(payload.get('with_soup', True))
with_staple = bool(payload.get('with_staple', True))
selected_ids = payload.get('selected_meat_ids', [])
meats = list(Dish.objects.filter(dish_type='meat', is_active=True))
vegs = list(Dish.objects.filter(dish_type='veg', is_active=True))
soups = list(Dish.objects.filter(dish_type='soup', is_active=True))
staples = list(Dish.objects.filter(dish_type='staple', is_active=True))
if payload.get('partial') or payload.get('selections'):
return _generate_partial_week(payload, meats, vegs, soups, staples, menu_key)
random.shuffle(vegs)
veg_pool = vegs[:]
random.shuffle(soups)
soup_pool = soups[:]
random.shuffle(staples)
staple_pool = staples[:]
random.shuffle(meats)
meat_pool = meats[:]
if auto_all:
day_meats = None
else:
selected = Dish.objects.filter(id__in=selected_ids, dish_type='meat')
day_meats = list(selected) + [d for d in meats if d not in selected]
day_meats = day_meats[:2]
week = []
for day in WEEKDAYS:
if auto_all:
day_meat_list = []
for _ in range(2):
item, meat_pool = _pop_next(meat_pool, meats)
day_meat_list.append(item)
else:
day_meat_list = day_meats[:] if day_meats else []
if len(day_meat_list) < 2:
extra, _ = _pick_unique([d for d in meats if d not in day_meat_list], 2 - len(day_meat_list))
day_meat_list += extra
day_veg = []
for _ in range(veg_per_day):
item, veg_pool = _pop_next(veg_pool, vegs)
day_veg.append(item)
soup = None
if with_soup and soup_pool:
soup, soup_pool = _pop_next(soup_pool, soups)
staple = None
if with_staple and staple_pool:
staple, staple_pool = _pop_next(staple_pool, staples)
week.append({
'day': day,
'meats': [{'id': d.id, 'name': d.name, 'ingredient': d.ingredient_detail, 'dish_type': d.dish_type, 'cuisine': d.cuisine,
'protein': float(d.protein), 'fat': float(d.fat), 'calorie': d.calorie} for d in day_meat_list],
'vegs': [{'id': d.id, 'name': d.name, 'ingredient': d.ingredient_detail, 'dish_type': d.dish_type, 'cuisine': d.cuisine,
'protein': float(d.protein), 'fat': float(d.fat), 'calorie': d.calorie} for d in day_veg],
'soup': {'id': soup.id, 'name': soup.name, 'ingredient': soup.ingredient_detail, 'dish_type': soup.dish_type, 'cuisine': soup.cuisine,
'protein': float(soup.protein), 'fat': float(soup.fat), 'calorie': soup.calorie} if soup else None,
'staple': {'id': staple.id, 'name': staple.name, 'ingredient': staple.ingredient_detail, 'dish_type': staple.dish_type, 'cuisine': staple.cuisine,
'protein': float(staple.protein), 'fat': float(staple.fat), 'calorie': staple.calorie} if staple else None,
})
return week
def build_menu_title(label, school_name=None, week_no=None):
school_name = school_name or SCHOOL_NAME
iso = date.today().isocalendar()
try:
week_no = int(week_no) if week_no is not None else iso.week
except (TypeError, ValueError):
week_no = iso.week
if not 1 <= week_no <= 53:
week_no = iso.week
try:
monday = date.fromisocalendar(iso.year, week_no, 1)
except ValueError:
monday = date.today() - timedelta(days=date.today().weekday())
friday = monday + timedelta(days=4)
date_range = f'{monday.month}.{monday.day}--{friday.month}.{friday.day}'
return f'{school_name}[第{week_no}周]{date_range}营养{label}菜单'
@api_view(['POST'])
def generate_menu(request):
data = request.data
school = data.get('school_name') or SCHOOL_NAME
week_no = data.get('week_no')
menus = {}
for label in ('A', 'B'):
key = label.lower()
menus[key] = {
'title': build_menu_title(label, school, week_no),
'week': generate_week_menu(data, menu_key=key),
}
return Response(menus)
def compute_daily_nutrition(week):
proteins, fats, calories = [], [], []
for day_menu in week:
items = list(day_menu['meats']) + list(day_menu['vegs'])
if day_menu.get('soup'):
items.append(day_menu['soup'])
if day_menu.get('staple'):
items.append(day_menu['staple'])
proteins.append(round(sum(x.get('protein', 0) or 0 for x in items), 1))
fats.append(round(sum(x.get('fat', 0) or 0 for x in items), 1))
calories.append(sum(x.get('calorie', 0) or 0 for x in items))
return min(proteins), max(fats), min(calories)
def nutrition_text(week):
min_p, max_f, min_c = compute_daily_nutrition(week)
return f'营养分析:蛋白质≥{min_p}g 脂肪≤{max_f}g 热量≥{min_c}kcal'
def build_week_sheet(ws, week, title, start_col=1):
headers = ['菜式', '主食', '荤菜', '素菜', '营养汤']
n_cols = len(headers)
off = start_col - 1
nutrition = nutrition_text(week)
ws.merge_cells(start_row=1, start_column=start_col, end_row=1, end_column=start_col + n_cols - 1)
cell = ws.cell(row=1, column=start_col, value=title)
cell.font = Font(size=16, bold=True)
cell.alignment = CENTER
ws.row_dimensions[1].height = 36
for col, name in enumerate(headers, start=1):
c = ws.cell(row=2, column=col + off, value=name)
c.font = Font(bold=True, color='FFFFFF')
c.fill = COLOR_HEADER
c.alignment = CENTER
c.border = BORDER
ws.row_dimensions[2].height = 26
def dish_lines(items):
lines = []
for d in items:
line = d['name']
if d.get('ingredient'):
line += f'\n({d["ingredient"]})'
lines.append(line)
return '\n'.join(lines)
for r, day_menu in enumerate(week, start=3):
values = [day_menu['day']]
values.append(dish_lines([day_menu['staple']]) if day_menu.get('staple') else '')
values.append(dish_lines(day_menu['meats']))
values.append(dish_lines(day_menu['vegs']))
values.append(dish_lines([day_menu['soup']]) if day_menu.get('soup') else '')
for col, val in enumerate(values, start=1):
c = ws.cell(row=r, column=col + off, value=val)
c.border = BORDER
if col == 1:
c.alignment = CENTER
c.fill = COLOR_DAY
c.font = Font(bold=True)
elif col == 2:
c.fill = COLOR_STAPLE
elif col == 3:
c.fill = COLOR_MEAT
elif col == 4:
c.fill = COLOR_VEG
else:
c.fill = COLOR_SOUP
if col > 1:
c.alignment = Alignment(vertical='top', horizontal='center', wrap_text=True)
ws.row_dimensions[r].height = 84
nut_row = len(week) + 3
ws.merge_cells(start_row=nut_row, start_column=start_col, end_row=nut_row, end_column=start_col + n_cols - 1)
nc = ws.cell(row=nut_row, column=start_col, value=nutrition)
nc.font = Font(bold=True, size=11)
nc.alignment = CENTER
nc.fill = PatternFill('solid', fgColor='FFF2CC')
for col in range(off + 1, off + n_cols + 1):
ws.cell(row=nut_row, column=col).border = BORDER
ws.row_dimensions[nut_row].height = 26
seen = {}
for day_menu in week:
for d in day_menu['meats'] + day_menu['vegs']:
seen.setdefault(d['id'], d)
dishes_detail = list(seen.values())
if dishes_detail:
detail_start = nut_row + 2
ws.merge_cells(start_row=detail_start, start_column=start_col, end_row=detail_start, end_column=start_col + n_cols - 1)
dc = ws.cell(row=detail_start, column=start_col, value='本周菜品营养分析(每份)')
dc.font = Font(size=13, bold=True)
dc.alignment = CENTER
dc.fill = COLOR_HEADER
for col in range(off + 1, off + n_cols + 1):
ws.cell(row=detail_start, column=col).border = BORDER
ws.row_dimensions[detail_start].height = 28
detail_headers = ['菜品', '类型', '蛋白质(g)', '脂肪(g)', '热量(kcal)']
for col, name in enumerate(detail_headers, start=1):
c = ws.cell(row=detail_start + 1, column=col + off, value=name)
c.font = Font(bold=True)
c.alignment = CENTER
c.border = BORDER
c.fill = PatternFill('solid', fgColor='D9E2F3')
ws.row_dimensions[detail_start + 1].height = 22
type_map = {'meat': '荤菜', 'veg': '素菜'}
for i, d in enumerate(dishes_detail, start=detail_start + 2):
row_vals = [d['name'], type_map.get(d.get('dish_type', ''), d.get('dish_type', '')),
d['protein'], d['fat'], d['calorie']]
for col, val in enumerate(row_vals, start=1):
c = ws.cell(row=i, column=col + off, value=val)
c.alignment = CENTER
c.border = BORDER
if col == 3 or col == 4:
c.number_format = '0.0'
if d.get('dish_type') == 'meat':
for col in range(off + 1, off + n_cols + 1):
ws.cell(row=i, column=col).fill = COLOR_MEAT
else:
for col in range(off + 1, off + n_cols + 1):
ws.cell(row=i, column=col).fill = COLOR_VEG
ws.row_dimensions[i].height = 20
total_row = detail_start + 2 + len(dishes_detail)
ws.merge_cells(start_row=total_row, start_column=start_col, end_row=total_row, end_column=start_col + n_cols - 1)
tc = ws.cell(row=total_row, column=start_col, value=nutrition)
tc.font = Font(bold=True, size=11)
tc.alignment = CENTER
tc.fill = PatternFill('solid', fgColor='FFF2CC')
for col in range(off + 1, off + n_cols + 1):
ws.cell(row=total_row, column=col).border = BORDER
ws.row_dimensions[total_row].height = 24
widths = {1: 10, 2: 16, 3: 34, 4: 34, 5: 28}
for i in range(n_cols):
ws.column_dimensions[get_column_letter(start_col + i)].width = widths[i + 1]
return total_row if dishes_detail else nut_row
@api_view(['POST'])
def export_excel(request):
wb = Workbook()
ws = wb.active
ws.title = '周菜单'
menus = request.data.get('menus')
last_row = 0
if menus and isinstance(menus, list):
labels = {m.get('label', ''): m for m in menus}
a = labels.get('营养A菜单') or labels.get('A')
b = labels.get('营养B菜单') or labels.get('B')
if a and a.get('week'):
last_row = max(last_row, build_week_sheet(ws, a['week'], a.get('title', '营养A菜单'), start_col=1))
if b and b.get('week'):
last_row = max(last_row, build_week_sheet(ws, b['week'], b.get('title', '营养B菜单'), start_col=7))
else:
week = request.data.get('week')
if not week:
return Response({'detail': '缺少 week 数据'}, status=status.HTTP_400_BAD_REQUEST)
title = request.data.get('title') or build_menu_title('A')
last_row = build_week_sheet(ws, week, title, start_col=1)
ws.freeze_panes = 'B3'
ws.print_area = f'A1:K{last_row}'
response = HttpResponse(
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
week_no = date.today().isocalendar().week
response['Content-Disposition'] = f'attachment; filename="学校订餐周菜单_第{week_no}周.xlsx"'
wb.save(response)
return response