import bleach import markdown from django.contrib.auth.decorators import login_required from django.db.models import Q, Prefetch from django.http import HttpResponse, JsonResponse from django.shortcuts import get_object_or_404, redirect, render from .forms import ( BoxForm, BoxTypeForm, ThingFileForm, ThingForm, ThingFormSet, ThingLinkForm, ThingPictureForm, ) from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink def _strip_markdown(text, max_length=100): """Convert Markdown to plain text and truncate.""" if not text: return '' html = markdown.markdown(text) plain_text = bleach.clean(html, tags=[], strip=True) plain_text = ' '.join(plain_text.split()) if len(plain_text) > max_length: return plain_text[:max_length].rsplit(' ', 1)[0] + '...' return plain_text @login_required def index(request): """Home page with boxes and tags.""" boxes = Box.objects.select_related('box_type').all().order_by('id') facets = Facet.objects.all().prefetch_related('tags') facet_tag_counts = {} for facet in facets: for tag in facet.tags.all(): count = tag.things.count() if count > 0: if facet not in facet_tag_counts: facet_tag_counts[facet] = [] facet_tag_counts[facet].append((tag, count)) return render(request, 'boxes/index.html', { 'boxes': boxes, 'facets': facets, 'facet_tag_counts': facet_tag_counts, }) @login_required def box_detail(request, box_id): """Display contents of a box.""" box = get_object_or_404(Box, pk=box_id) things = box.things.prefetch_related('tags').all() return render(request, 'boxes/box_detail.html', { 'box': box, 'things': things, }) @login_required def thing_detail(request, thing_id): """Display details of a thing (read-only).""" thing = get_object_or_404( Thing.objects.select_related('box', 'box__box_type').prefetch_related('files', 'links', 'tags'), pk=thing_id ) return render(request, 'boxes/thing_detail.html', {'thing': thing}) @login_required def edit_thing(request, thing_id): """Edit a thing's details.""" thing = get_object_or_404( Thing.objects.select_related('box', 'box__box_type').prefetch_related('files', 'links', 'tags'), pk=thing_id ) boxes = Box.objects.select_related('box_type').all().order_by('id') facets = Facet.objects.all().prefetch_related('tags') picture_form = ThingPictureForm(instance=thing) file_form = ThingFileForm() link_form = ThingLinkForm() if request.method == 'POST': action = request.POST.get('action') if action == 'save_details': form = ThingForm(request.POST, request.FILES, instance=thing) if form.is_valid(): form.save() return redirect('thing_detail', thing_id=thing.id) elif action == 'move': new_box_id = request.POST.get('new_box') if new_box_id: new_box = get_object_or_404(Box, pk=new_box_id) thing.box = new_box thing.save() return redirect('edit_thing', thing_id=thing.id) elif action == 'upload_picture': picture_form = ThingPictureForm(request.POST, request.FILES, instance=thing) if picture_form.is_valid(): picture_form.save() return redirect('edit_thing', thing_id=thing.id) elif action == 'delete_picture': if thing.picture: thing.picture.delete() thing.picture = None thing.save() return redirect('edit_thing', thing_id=thing.id) elif action == 'add_file': file_form = ThingFileForm(request.POST, request.FILES) if file_form.is_valid(): thing_file = file_form.save(commit=False) thing_file.thing = thing thing_file.save() return redirect('edit_thing', thing_id=thing.id) elif action == 'add_link': link_form = ThingLinkForm(request.POST) if link_form.is_valid(): thing_link = link_form.save(commit=False) thing_link.thing = thing thing_link.save() return redirect('edit_thing', thing_id=thing.id) elif action == 'delete_file': file_id = request.POST.get('file_id') if file_id: try: thing_file = ThingFile.objects.get(pk=file_id, thing=thing) thing_file.file.delete() thing_file.delete() except ThingFile.DoesNotExist: pass return redirect('edit_thing', thing_id=thing.id) elif action == 'delete_link': link_id = request.POST.get('link_id') if link_id: try: thing_link = ThingLink.objects.get(pk=link_id, thing=thing) thing_link.delete() except ThingLink.DoesNotExist: pass return redirect('edit_thing', thing_id=thing.id) elif action == 'add_tag': tag_id = request.POST.get('tag_id') if tag_id: try: tag = Tag.objects.get(pk=tag_id) if tag.facet.cardinality == Facet.Cardinality.SINGLE: existing_tags = list(thing.tags.filter(facet=tag.facet)) for existing_tag in existing_tags: thing.tags.remove(existing_tag) thing.tags.add(tag) except Tag.DoesNotExist: pass return redirect('edit_thing', thing_id=thing.id) elif action == 'remove_tag': tag_id = request.POST.get('tag_id') if tag_id: try: tag = Tag.objects.get(pk=tag_id) thing.tags.remove(tag) except Tag.DoesNotExist: pass return redirect('edit_thing', thing_id=thing.id) thing_form = ThingForm(instance=thing) return render(request, 'boxes/edit_thing.html', { 'thing': thing, 'boxes': boxes, 'facets': facets, 'picture_form': picture_form, 'file_form': file_form, 'link_form': link_form, 'thing_form': thing_form, }) @login_required def search(request): """Search page for things.""" return render(request, 'boxes/search.html') @login_required def search_api(request): """AJAX endpoint for searching things.""" query = request.GET.get('q', '').strip() if len(query) < 2: return JsonResponse({'results': []}) # Check for "Facet:Word" format if ':' in query: parts = query.split(':',1) facet_name = parts[0].strip() tag_name = parts[1].strip() # Search for things with specific facet and tag things = Thing.objects.filter( Q(tags__facet__name__icontains=facet_name) & Q(tags__name__icontains=tag_name) ).prefetch_related('files', 'links', 'tags').select_related('box').distinct()[:50] else: # Normal search things = Thing.objects.filter( Q(name__icontains=query) | Q(description__icontains=query) | Q(files__title__icontains=query) | Q(files__file__icontains=query) | Q(links__title__icontains=query) | Q(links__url__icontains=query) | Q(tags__name__icontains=query) | Q(tags__facet__name__icontains=query) ).prefetch_related('files', 'links', 'tags').select_related('box').distinct()[:50] results = [ { 'id': thing.id, 'name': thing.name, 'box': thing.box.id, 'description': _strip_markdown(thing.description), 'tags': [ { 'name': tag.name, 'color': tag.facet.color, } for tag in thing.tags.all() ], 'files': [ { 'title': f.title, 'filename': f.filename(), } for f in thing.files.all() ], 'links': [ { 'title': l.title, 'url': l.url, } for l in thing.links.all() ], } for thing in things ] return JsonResponse({'results': results}) @login_required def add_things(request, box_id): """Add multiple things to a box at once.""" box = get_object_or_404(Box, pk=box_id) success_message = None if request.method == 'POST': formset = ThingFormSet(request.POST, request.FILES, queryset=Thing.objects.filter(box=box)) if formset.is_valid(): things = formset.save(commit=False) created_count = 0 for thing in things: if thing.name or thing.description or thing.picture: thing.box = box thing.save() created_count += 1 if created_count > 0: success_message = f'Added {created_count} thing{"s" if created_count > 1 else ""} successfully.' formset = ThingFormSet(queryset=Thing.objects.filter(box=box)) else: formset = ThingFormSet(queryset=Thing.objects.filter(box=box)) return render(request, 'boxes/add_things.html', { 'box': box, 'formset': formset, 'success_message': success_message, }) @login_required def box_management(request): """Main page for managing boxes and box types.""" box_types = BoxType.objects.all().prefetch_related('boxes') boxes = Box.objects.select_related('box_type').all().prefetch_related('things') box_type_form = BoxTypeForm() box_form = BoxForm() return render(request, 'boxes/box_management.html', { 'box_types': box_types, 'boxes': boxes, 'box_type_form': box_type_form, 'box_form': box_form, }) @login_required def add_box_type(request): """Add a new box type.""" if request.method == 'POST': form = BoxTypeForm(request.POST) if form.is_valid(): form.save() return redirect('box_management') @login_required def edit_box_type(request, type_id): """Edit an existing box type.""" box_type = get_object_or_404(BoxType, pk=type_id) if request.method == 'POST': form = BoxTypeForm(request.POST, instance=box_type) if form.is_valid(): form.save() return redirect('box_management') @login_required def delete_box_type(request, type_id): """Delete a box type.""" box_type = get_object_or_404(BoxType, pk=type_id) if request.method == 'POST': if box_type.boxes.exists(): return redirect('box_management') box_type.delete() return redirect('box_management') @login_required def add_box(request): """Add a new box.""" if request.method == 'POST': form = BoxForm(request.POST) if form.is_valid(): form.save() return redirect('box_management') @login_required def edit_box(request, box_id): """Edit an existing box.""" box = get_object_or_404(Box, pk=box_id) if request.method == 'POST': form = BoxForm(request.POST, instance=box) if form.is_valid(): form.save() return redirect('box_management') @login_required def delete_box(request, box_id): """Delete a box.""" box = get_object_or_404(Box, pk=box_id) if request.method == 'POST': if box.things.exists(): return redirect('box_management') box.delete() return redirect('box_management') @login_required def resources_list(request): """List all links and files from things that have them.""" things_with_files = Thing.objects.filter(files__isnull=False).prefetch_related('files').distinct() things_with_links = Thing.objects.filter(links__isnull=False).prefetch_related('links').distinct() all_things = (things_with_files | things_with_links).distinct().order_by('name') resources = [] for thing in all_things.prefetch_related('files', 'links'): for file in thing.files.all(): resources.append({ 'type': 'file', 'thing_name': thing.name, 'thing_id': thing.id, 'title': file.title, 'url': file.file.url, }) for link in thing.links.all(): resources.append({ 'type': 'link', 'thing_name': thing.name, 'thing_id': thing.id, 'title': link.title, 'url': link.url, }) return render(request, 'boxes/resources_list.html', { 'resources': resources, }) @login_required def fixme(request): """Page to find and fix things missing tags for specific facets.""" facets = Facet.objects.all().prefetch_related('tags') selected_facet = None missing_things = [] if request.method == 'GET' and 'facet_id' in request.GET: try: selected_facet = Facet.objects.get(pk=request.GET['facet_id']) # Find things that don't have any tag from this facet missing_things = Thing.objects.exclude( tags__facet=selected_facet ).select_related('box', 'box__box_type').prefetch_related('tags') except Facet.DoesNotExist: selected_facet = None elif request.method == 'POST': facet_id = request.POST.get('facet_id') tag_ids = request.POST.getlist('tag_ids') thing_ids = request.POST.getlist('thing_ids') if facet_id and tag_ids and thing_ids: facet = get_object_or_404(Facet, pk=facet_id) tags = Tag.objects.filter(id__in=tag_ids, facet=facet) things = Thing.objects.filter(id__in=thing_ids) for thing in things: if facet.cardinality == Facet.Cardinality.SINGLE: # Remove existing tags from this facet thing.tags.remove(*thing.tags.filter(facet=facet)) # Add new tags for tag in tags: if tag.facet == facet: thing.tags.add(tag) return redirect('fixme') return render(request, 'boxes/fixme.html', { 'facets': facets, 'selected_facet': selected_facet, 'missing_things': missing_things, })