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

1import os 

2import re 

3from datetime import datetime 

4from io import StringIO 

5from uuid import UUID 

6import subprocess 

7 

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 

23 

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 

34 

35 

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')) 

42 

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 } 

49 

50 return HttpResponseRedirect(page_map.get(request.user.userpreference.default_page)) 

51 except UserPreference.DoesNotExist: 

52 return HttpResponseRedirect(reverse('view_search')) 

53 

54 

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) 

64 

65 

66def no_groups(request): 

67 return render(request, 'no_groups_info.html') 

68 

69 

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 ) 

87 

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()) 

90 

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])) 

94 

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', '')])) 

104 

105 create_form = SpaceCreateForm(initial={'name': f'{request.user.get_user_display_name()}\'s Space'}) 

106 join_form = SpaceJoinForm() 

107 

108 return render(request, 'space_overview.html', {'create_form': create_form, 'join_form': join_form}) 

109 

110 

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')) 

116 

117 

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') 

123 

124 

125def recipe_view(request, pk, share=None): 

126 with scopes_disabled(): 

127 recipe = get_object_or_404(Recipe, pk=pk) 

128 

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) 

133 

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')) 

140 

141 comments = Comment.objects.filter(recipe__space=request.space, recipe=recipe) 

142 

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})) 

148 

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() 

156 

157 messages.add_message(request, messages.SUCCESS, _('Comment saved!')) 

158 

159 comment_form = CommentForm() 

160 

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) 

166 

167 return render(request, 'recipe_view.html', 

168 {'recipe': recipe, 'comments': comments, 'comment_form': comment_form, 'share': share, }) 

169 

170 

171@group_required('user') 

172def books(request): 

173 return render(request, 'books.html', {}) 

174 

175 

176@group_required('user') 

177def meal_plan(request): 

178 return render(request, 'meal_plan.html', {}) 

179 

180 

181@group_required('user') 

182def supermarket(request): 

183 return render(request, 'supermarket.html', {}) 

184 

185 

186@group_required('user') 

187def view_profile(request, user_id): 

188 return render(request, 'profile.html', {}) 

189 

190 

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') 

196 

197 return render(request, 'user_settings.html', {}) 

198 

199 

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 

206 

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) 

211 

212 

213@group_required('user') 

214def property_editor(request, pk): 

215 return render(request, 'property_editor.html', {'recipe_id': pk}) 

216 

217 

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') 

223 

224 sp = request.user.searchpreference 

225 search_error = False 

226 

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 

284 

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() 

291 

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() 

298 

299 return render(request, 'settings.html', { 

300 'search_form': search_form, 

301 }) 

302 

303 

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}) 

317 

318 

319def system(request): 

320 if not request.user.is_superuser: 

321 return HttpResponseRedirect(reverse('index')) 

322 

323 postgres_ver = None 

324 postgres = settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql' 

325 

326 if postgres: 

327 postgres_current = 16 # will need to be updated as PostgreSQL releases new major versions 

328 from decimal import Decimal 

329 

330 from django.db import connection 

331 

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.') 

345 

346 secret_key = False if os.getenv('SECRET_KEY') else True 

347 

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() 

353 

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} 

370 

371 for key in migration_info.keys(): 

372 migration_info[key]['total'] = len(migration_info[key]['unapplied_migrations']) + len(migration_info[key]['applied_migrations']) 

373 

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 }) 

388 

389 

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')) 

396 

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() 

408 

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() 

416 

417 return render(request, 'setup.html', {'form': form}) 

418 

419 

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')) 

427 

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() 

433 

434 user_space = UserSpace.objects.create(user=request.user, space=link.space, internal_note=link.internal_note, invite_link=link, active=False) 

435 

436 if request.user.userspace_set.count() == 1: 

437 user_space.active = True 

438 user_space.save() 

439 

440 user_space.groups.add(link.group) 

441 

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')) 

447 

448 messages.add_message(request, messages.ERROR, _('Invite Link not valid or already used!')) 

449 return HttpResponseRedirect(reverse('view_space_overview')) 

450 

451 

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', {}) 

460 

461 

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')) 

473 

474 

475def markdown_info(request): 

476 return render(request, 'markdown_info.html', {}) 

477 

478 

479def search_info(request): 

480 return render(request, 'search_info.html', {}) 

481 

482 

483@group_required('guest') 

484def api_info(request): 

485 return render(request, 'api_info.html', {}) 

486 

487 

488def offline(request): 

489 return render(request, 'offline.html', {}) 

490 

491 

492def test(request): 

493 if not settings.DEBUG: 

494 return HttpResponseRedirect(reverse('index')) 

495 

496 from cookbook.helper.ingredient_parser import IngredientParser 

497 parser = IngredientParser(request, False) 

498 

499 data = { 

500 'original': '90g golden syrup' 

501 } 

502 data['parsed'] = parser.parse(data['original']) 

503 

504 return render(request, 'test.html', {'data': data}) 

505 

506 

507def test2(request): 

508 if not settings.DEBUG: 

509 return HttpResponseRedirect(reverse('index')) 

510 

511 

512def get_orphan_files(delete_orphans=False): 

513 # Get list of all image files in media folder 

514 media_dir = settings.MEDIA_ROOT 

515 

516 def find_orphans(): 

517 image_files = [] 

518 for root, dirs, files in os.walk(media_dir): 

519 for file in files: 

520 

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)) 

525 

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)) 

532 

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) 

539 

540 # Check each image file against model image fields 

541 return [img for img in image_files if img[0] not in image_paths] 

542 

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() 

553 

554 return [img[1] for img in orphans]