Coverage for cookbook/views/views.py: 27%
363 statements
« prev ^ index » next coverage.py v7.4.0, created at 2023-12-29 00:47 +0100
« prev ^ index » next coverage.py v7.4.0, created at 2023-12-29 00:47 +0100
1import os
2import re
3from datetime import datetime
4from io import StringIO
5from uuid import UUID
6import subprocess
8from django.apps import apps
9from django.conf import settings
10from django.contrib import messages
11from django.contrib.auth.decorators import login_required
12from django.contrib.auth.models import Group
13from django.contrib.auth.password_validation import validate_password
14from django.core.exceptions import ValidationError
15from django.core.management import call_command
16from django.db import models
17from django.http import HttpResponseRedirect
18from django.shortcuts import get_object_or_404, redirect, render
19from django.urls import reverse, reverse_lazy
20from django.utils import timezone
21from django.utils.translation import gettext as _
22from django_scopes import scopes_disabled
24from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, SpaceCreateForm,
25 SpaceJoinForm, User, UserCreateForm, UserPreference)
26from cookbook.helper.HelperFunctions import str2bool
27from cookbook.helper.permission_helper import (group_required, has_group_permission,
28 share_link_valid, switch_user_active_space)
29from cookbook.models import (Comment, CookLog, InviteLink, SearchFields, SearchPreference,
30 ShareLink, Space, UserSpace, ViewLog)
31from cookbook.tables import CookLogTable, ViewLogTable
32from cookbook.version_info import VERSION_INFO
33from recipes.settings import PLUGINS, BASE_DIR
36def index(request):
37 with scopes_disabled():
38 if not request.user.is_authenticated:
39 if User.objects.count() < 1 and 'django.contrib.auth.backends.RemoteUserBackend' not in settings.AUTHENTICATION_BACKENDS:
40 return HttpResponseRedirect(reverse_lazy('view_setup'))
41 return HttpResponseRedirect(reverse_lazy('view_search'))
43 try:
44 page_map = {
45 UserPreference.SEARCH: reverse_lazy('view_search'),
46 UserPreference.PLAN: reverse_lazy('view_plan'),
47 UserPreference.BOOKS: reverse_lazy('view_books'),
48 }
50 return HttpResponseRedirect(page_map.get(request.user.userpreference.default_page))
51 except UserPreference.DoesNotExist:
52 return HttpResponseRedirect(reverse('view_search'))
55# TODO need to deprecate
56def search(request):
57 if has_group_permission(request.user, ('guest',)):
58 return render(request, 'search.html', {})
59 else:
60 if request.user.is_authenticated:
61 return HttpResponseRedirect(reverse('view_no_group'))
62 else:
63 return HttpResponseRedirect(reverse('account_login') + '?next=' + request.path)
66def no_groups(request):
67 return render(request, 'no_groups_info.html')
70@login_required
71def space_overview(request):
72 if request.POST:
73 create_form = SpaceCreateForm(request.POST, prefix='create')
74 join_form = SpaceJoinForm(request.POST, prefix='join')
75 if settings.HOSTED and request.user.username == 'demo':
76 messages.add_message(request, messages.WARNING, _('This feature is not available in the demo version!'))
77 else:
78 if create_form.is_valid():
79 created_space = Space.objects.create(
80 name=create_form.cleaned_data['name'],
81 created_by=request.user,
82 max_file_storage_mb=settings.SPACE_DEFAULT_MAX_FILES,
83 max_recipes=settings.SPACE_DEFAULT_MAX_RECIPES,
84 max_users=settings.SPACE_DEFAULT_MAX_USERS,
85 allow_sharing=settings.SPACE_DEFAULT_ALLOW_SHARING,
86 )
88 user_space = UserSpace.objects.create(space=created_space, user=request.user, active=False)
89 user_space.groups.add(Group.objects.filter(name='admin').get())
91 messages.add_message(request, messages.SUCCESS,
92 _('You have successfully created your own recipe space. Start by adding some recipes or invite other people to join you.'))
93 return HttpResponseRedirect(reverse('view_switch_space', args=[user_space.space.pk]))
95 if join_form.is_valid():
96 return HttpResponseRedirect(reverse('view_invite', args=[join_form.cleaned_data['token']]))
97 else:
98 if settings.SOCIAL_DEFAULT_ACCESS and len(request.user.userspace_set.all()) == 0:
99 user_space = UserSpace.objects.create(space=Space.objects.first(), user=request.user, active=False)
100 user_space.groups.add(Group.objects.filter(name=settings.SOCIAL_DEFAULT_GROUP).get())
101 return HttpResponseRedirect(reverse('index'))
102 if 'signup_token' in request.session:
103 return HttpResponseRedirect(reverse('view_invite', args=[request.session.pop('signup_token', '')]))
105 create_form = SpaceCreateForm(initial={'name': f'{request.user.get_user_display_name()}\'s Space'})
106 join_form = SpaceJoinForm()
108 return render(request, 'space_overview.html', {'create_form': create_form, 'join_form': join_form})
111@login_required
112def switch_space(request, space_id):
113 space = get_object_or_404(Space, id=space_id)
114 switch_user_active_space(request.user, space)
115 return HttpResponseRedirect(reverse('index'))
118def no_perm(request):
119 if not request.user.is_authenticated:
120 messages.add_message(request, messages.ERROR, _('You are not logged in and therefore cannot view this page!'))
121 return HttpResponseRedirect(reverse('account_login') + '?next=' + request.GET.get('next', '/search/'))
122 return render(request, 'no_perm_info.html')
125def recipe_view(request, pk, share=None):
126 with scopes_disabled():
127 recipe = get_object_or_404(Recipe, pk=pk)
129 if not request.user.is_authenticated and not share_link_valid(recipe, share):
130 messages.add_message(request, messages.ERROR,
131 _('You do not have the required permissions to view this page!'))
132 return HttpResponseRedirect(reverse('account_login') + '?next=' + request.path)
134 if not (has_group_permission(request.user,
135 ('guest',)) and recipe.space == request.space) and not share_link_valid(recipe,
136 share):
137 messages.add_message(request, messages.ERROR,
138 _('You do not have the required permissions to view this page!'))
139 return HttpResponseRedirect(reverse('index'))
141 comments = Comment.objects.filter(recipe__space=request.space, recipe=recipe)
143 if request.method == "POST":
144 if not request.user.is_authenticated:
145 messages.add_message(request, messages.ERROR,
146 _('You do not have the required permissions to perform this action!'))
147 return HttpResponseRedirect(reverse('view_recipe', kwargs={'pk': recipe.pk, 'share': share}))
149 comment_form = CommentForm(request.POST, prefix='comment')
150 if comment_form.is_valid():
151 comment = Comment()
152 comment.recipe = recipe
153 comment.text = comment_form.cleaned_data['text']
154 comment.created_by = request.user
155 comment.save()
157 messages.add_message(request, messages.SUCCESS, _('Comment saved!'))
159 comment_form = CommentForm()
161 if request.user.is_authenticated:
162 if not ViewLog.objects.filter(recipe=recipe, created_by=request.user,
163 created_at__gt=(timezone.now() - timezone.timedelta(minutes=5)),
164 space=request.space).exists():
165 ViewLog.objects.create(recipe=recipe, created_by=request.user, space=request.space)
167 return render(request, 'recipe_view.html',
168 {'recipe': recipe, 'comments': comments, 'comment_form': comment_form, 'share': share, })
171@group_required('user')
172def books(request):
173 return render(request, 'books.html', {})
176@group_required('user')
177def meal_plan(request):
178 return render(request, 'meal_plan.html', {})
181@group_required('user')
182def supermarket(request):
183 return render(request, 'supermarket.html', {})
186@group_required('user')
187def view_profile(request, user_id):
188 return render(request, 'profile.html', {})
191@group_required('guest')
192def user_settings(request):
193 if request.space.demo:
194 messages.add_message(request, messages.ERROR, _('This feature is not available in the demo version!'))
195 return redirect('index')
197 return render(request, 'user_settings.html', {})
200@group_required('user')
201def ingredient_editor(request):
202 template_vars = {'food_id': -1, 'unit_id': -1}
203 food_id = request.GET.get('food_id', None)
204 if food_id and re.match(r'^(\d)+$', food_id):
205 template_vars['food_id'] = food_id
207 unit_id = request.GET.get('unit_id', None)
208 if unit_id and re.match(r'^(\d)+$', unit_id):
209 template_vars['unit_id'] = unit_id
210 return render(request, 'ingredient_editor.html', template_vars)
213@group_required('user')
214def property_editor(request, pk):
215 return render(request, 'property_editor.html', {'recipe_id': pk})
218@group_required('guest')
219def shopping_settings(request):
220 if request.space.demo:
221 messages.add_message(request, messages.ERROR, _('This feature is not available in the demo version!'))
222 return redirect('index')
224 sp = request.user.searchpreference
225 search_error = False
227 if request.method == "POST":
228 if 'search_form' in request.POST:
229 search_form = SearchPreferenceForm(request.POST, prefix='search')
230 if search_form.is_valid():
231 if not sp:
232 sp = SearchPreferenceForm(user=request.user)
233 fields_searched = (
234 len(search_form.cleaned_data['icontains'])
235 + len(search_form.cleaned_data['istartswith'])
236 + len(search_form.cleaned_data['trigram'])
237 + len(search_form.cleaned_data['fulltext'])
238 )
239 if search_form.cleaned_data['preset'] == 'fuzzy':
240 sp.search = SearchPreference.SIMPLE
241 sp.lookup = True
242 sp.unaccent.set([SearchFields.objects.get(name='Name')])
243 sp.icontains.set([SearchFields.objects.get(name='Name')])
244 sp.istartswith.clear()
245 sp.trigram.set([SearchFields.objects.get(name='Name')])
246 sp.fulltext.clear()
247 sp.trigram_threshold = 0.2
248 sp.save()
249 elif search_form.cleaned_data['preset'] == 'precise':
250 sp.search = SearchPreference.WEB
251 sp.lookup = True
252 sp.unaccent.set(SearchFields.objects.all())
253 # full text on food is very slow, add search_vector field and index it (including Admin functions and postsave signal to rebuild index)
254 sp.icontains.set([SearchFields.objects.get(name='Name')])
255 sp.istartswith.set([SearchFields.objects.get(name='Name')])
256 sp.trigram.clear()
257 sp.fulltext.set(SearchFields.objects.filter(name__in=['Ingredients']))
258 sp.trigram_threshold = 0.2
259 sp.save()
260 elif fields_searched == 0:
261 search_form.add_error(None, _('You must select at least one field to search!'))
262 search_error = True
263 elif search_form.cleaned_data['search'] in ['websearch', 'raw'] and len(
264 search_form.cleaned_data['fulltext']) == 0:
265 search_form.add_error('search',
266 _('To use this search method you must select at least one full text search field!'))
267 search_error = True
268 elif search_form.cleaned_data['search'] in ['websearch', 'raw'] and len(
269 search_form.cleaned_data['trigram']) > 0:
270 search_form.add_error(None, _('Fuzzy search is not compatible with this search method!'))
271 search_error = True
272 else:
273 sp.search = search_form.cleaned_data['search']
274 sp.lookup = search_form.cleaned_data['lookup']
275 sp.unaccent.set(search_form.cleaned_data['unaccent'])
276 sp.icontains.set(search_form.cleaned_data['icontains'])
277 sp.istartswith.set(search_form.cleaned_data['istartswith'])
278 sp.trigram.set(search_form.cleaned_data['trigram'])
279 sp.fulltext.set(search_form.cleaned_data['fulltext'])
280 sp.trigram_threshold = search_form.cleaned_data['trigram_threshold']
281 sp.save()
282 else:
283 search_error = True
285 fields_searched = len(sp.icontains.all()) + len(sp.istartswith.all()) + len(sp.trigram.all()) + len(
286 sp.fulltext.all())
287 if sp and not search_error and fields_searched > 0:
288 search_form = SearchPreferenceForm(instance=sp)
289 elif not search_error:
290 search_form = SearchPreferenceForm()
292 # these fields require postgresql - just disable them if postgresql isn't available
293 if not settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql':
294 sp.search = SearchPreference.SIMPLE
295 sp.trigram.clear()
296 sp.fulltext.clear()
297 sp.save()
299 return render(request, 'settings.html', {
300 'search_form': search_form,
301 })
304@group_required('guest')
305def history(request):
306 view_log = ViewLogTable(
307 ViewLog.objects.filter(
308 created_by=request.user, space=request.space
309 ).order_by('-created_at').all()
310 )
311 cook_log = CookLogTable(
312 CookLog.objects.filter(
313 created_by=request.user
314 ).order_by('-created_at').all()
315 )
316 return render(request, 'history.html', {'view_log': view_log, 'cook_log': cook_log})
319def system(request):
320 if not request.user.is_superuser:
321 return HttpResponseRedirect(reverse('index'))
323 postgres_ver = None
324 postgres = settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql'
326 if postgres:
327 postgres_current = 16 # will need to be updated as PostgreSQL releases new major versions
328 from decimal import Decimal
330 from django.db import connection
332 postgres_ver = Decimal(str(connection.pg_version).replace('00', '.'))
333 if postgres_ver >= postgres_current:
334 database_status = 'success'
335 database_message = _('Everything is fine!')
336 elif postgres_ver < postgres_current - 2:
337 database_status = 'danger'
338 database_message = _('PostgreSQL %(v)s is deprecated. Upgrade to a fully supported version!') % {'v': postgres_ver}
339 else:
340 database_status = 'info'
341 database_message = _('You are running PostgreSQL %(v1)s. PostgreSQL %(v2)s is recommended') % {'v1': postgres_ver, 'v2': postgres_current}
342 else:
343 database_status = 'info'
344 database_message = _('This application is not running with a Postgres database backend. This is ok but not recommended as some features only work with postgres databases.')
346 secret_key = False if os.getenv('SECRET_KEY') else True
348 if request.method == "POST":
349 del_orphans = request.POST.get('delete_orphans')
350 orphans = get_orphan_files(delete_orphans=str2bool(del_orphans))
351 else:
352 orphans = get_orphan_files()
354 out = StringIO()
355 call_command('showmigrations', stdout=out)
356 missing_migration = False
357 migration_info = {}
358 current_app = None
359 for row in out.getvalue().splitlines():
360 if '[ ]' in row and current_app:
361 migration_info[current_app]['unapplied_migrations'].append(row.replace('[ ]', ''))
362 missing_migration = True
363 elif '[X]' in row and current_app:
364 migration_info[current_app]['applied_migrations'].append(row.replace('[x]', ''))
365 elif '(no migrations)' in row and current_app:
366 pass
367 else:
368 current_app = row
369 migration_info[current_app] = {'app': current_app, 'unapplied_migrations': [], 'applied_migrations': [], 'total': 0}
371 for key in migration_info.keys():
372 migration_info[key]['total'] = len(migration_info[key]['unapplied_migrations']) + len(migration_info[key]['applied_migrations'])
374 return render(request, 'system.html', {
375 'gunicorn_media': settings.GUNICORN_MEDIA,
376 'debug': settings.DEBUG,
377 'postgres': postgres,
378 'postgres_version': postgres_ver,
379 'postgres_status': database_status,
380 'postgres_message': database_message,
381 'version_info': VERSION_INFO,
382 'plugins': PLUGINS,
383 'secret_key': secret_key,
384 'orphans': orphans,
385 'migration_info': migration_info,
386 'missing_migration': missing_migration,
387 })
390def setup(request):
391 with scopes_disabled():
392 if User.objects.count() > 0 or 'django.contrib.auth.backends.RemoteUserBackend' in settings.AUTHENTICATION_BACKENDS:
393 messages.add_message(request, messages.ERROR,
394 _('The setup page can only be used to create the first user! If you have forgotten your superuser credentials please consult the django documentation on how to reset passwords.'))
395 return HttpResponseRedirect(reverse('account_login'))
397 if request.method == 'POST':
398 form = UserCreateForm(request.POST)
399 if form.is_valid():
400 if form.cleaned_data['password'] != form.cleaned_data['password_confirm']:
401 form.add_error('password', _('Passwords dont match!'))
402 else:
403 user = User(username=form.cleaned_data['name'], is_superuser=True, is_staff=True)
404 try:
405 validate_password(form.cleaned_data['password'], user=user)
406 user.set_password(form.cleaned_data['password'])
407 user.save()
409 messages.add_message(request, messages.SUCCESS, _('User has been created, please login!'))
410 return HttpResponseRedirect(reverse('account_login'))
411 except ValidationError as e:
412 for m in e:
413 form.add_error('password', m)
414 else:
415 form = UserCreateForm()
417 return render(request, 'setup.html', {'form': form})
420def invite_link(request, token):
421 with scopes_disabled():
422 try:
423 token = UUID(token, version=4)
424 except ValueError:
425 messages.add_message(request, messages.ERROR, _('Malformed Invite Link supplied!'))
426 return HttpResponseRedirect(reverse('index'))
428 if link := InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, uuid=token).first():
429 if request.user.is_authenticated and not request.user.userspace_set.filter(space=link.space).exists():
430 if not link.reusable:
431 link.used_by = request.user
432 link.save()
434 user_space = UserSpace.objects.create(user=request.user, space=link.space, internal_note=link.internal_note, invite_link=link, active=False)
436 if request.user.userspace_set.count() == 1:
437 user_space.active = True
438 user_space.save()
440 user_space.groups.add(link.group)
442 messages.add_message(request, messages.SUCCESS, _('Successfully joined space.'))
443 return HttpResponseRedirect(reverse('view_space_overview'))
444 else:
445 request.session['signup_token'] = str(token)
446 return HttpResponseRedirect(reverse('account_signup'))
448 messages.add_message(request, messages.ERROR, _('Invite Link not valid or already used!'))
449 return HttpResponseRedirect(reverse('view_space_overview'))
452@group_required('admin')
453def space_manage(request, space_id):
454 if request.space.demo:
455 messages.add_message(request, messages.ERROR, _('This feature is not available in the demo version!'))
456 return redirect('index')
457 space = get_object_or_404(Space, id=space_id)
458 switch_user_active_space(request.user, space)
459 return render(request, 'space_manage.html', {})
462def report_share_abuse(request, token):
463 if not settings.SHARING_ABUSE:
464 messages.add_message(request, messages.WARNING,
465 _('Reporting share links is not enabled for this instance. Please notify the page administrator to report problems.'))
466 else:
467 if link := ShareLink.objects.filter(uuid=token).first():
468 link.abuse_blocked = True
469 link.save()
470 messages.add_message(request, messages.WARNING,
471 _('Recipe sharing link has been disabled! For additional information please contact the page administrator.'))
472 return HttpResponseRedirect(reverse('index'))
475def markdown_info(request):
476 return render(request, 'markdown_info.html', {})
479def search_info(request):
480 return render(request, 'search_info.html', {})
483@group_required('guest')
484def api_info(request):
485 return render(request, 'api_info.html', {})
488def offline(request):
489 return render(request, 'offline.html', {})
492def test(request):
493 if not settings.DEBUG:
494 return HttpResponseRedirect(reverse('index'))
496 from cookbook.helper.ingredient_parser import IngredientParser
497 parser = IngredientParser(request, False)
499 data = {
500 'original': '90g golden syrup'
501 }
502 data['parsed'] = parser.parse(data['original'])
504 return render(request, 'test.html', {'data': data})
507def test2(request):
508 if not settings.DEBUG:
509 return HttpResponseRedirect(reverse('index'))
512def get_orphan_files(delete_orphans=False):
513 # Get list of all image files in media folder
514 media_dir = settings.MEDIA_ROOT
516 def find_orphans():
517 image_files = []
518 for root, dirs, files in os.walk(media_dir):
519 for file in files:
521 if not file.lower().endswith(('.db')) and not root.lower().endswith(('@eadir')):
522 full_path = os.path.join(root, file)
523 relative_path = os.path.relpath(full_path, media_dir)
524 image_files.append((relative_path, full_path))
526 # Get list of all image fields in models
527 image_fields = []
528 for model in apps.get_models():
529 for field in model._meta.get_fields():
530 if isinstance(field, models.ImageField) or isinstance(field, models.FileField):
531 image_fields.append((model, field.name))
533 # get all images in the database
534 # TODO I don't know why, but this completely bypasses scope limitations
535 image_paths = []
536 for model, field in image_fields:
537 image_field_paths = model.objects.values_list(field, flat=True)
538 image_paths.extend(image_field_paths)
540 # Check each image file against model image fields
541 return [img for img in image_files if img[0] not in image_paths]
543 orphans = find_orphans()
544 if delete_orphans:
545 for f in [img[1] for img in orphans]:
546 try:
547 os.remove(f)
548 except FileNotFoundError:
549 print(f"File not found: {f}")
550 except Exception as e:
551 print(f"Error deleting file {f}: {e}")
552 orphans = find_orphans()
554 return [img[1] for img in orphans]