Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-04-25 08:29:11

0001 """
0002 PCS web UI views and DataTable AJAX endpoints.
0003 
0004 Views are generic across tag types (p/e/s/r) where possible, parameterized by tag_type.
0005 Tag list views use server-side DataTables via monitor_app._datatable_base.html.
0006 Read operations are public; create/edit/lock require login.
0007 """
0008 import json
0009 from urllib.parse import quote as urlquote
0010 from django.shortcuts import render, get_object_or_404, redirect
0011 from django.contrib.auth.decorators import login_required
0012 from django.urls import reverse
0013 from django.http import JsonResponse
0014 from django.contrib import messages
0015 from django.db.models import Count
0016 
0017 from monitor_app.utils import DataTablesProcessor, get_filter_params, format_datetime
0018 
0019 from .models import (
0020     PhysicsCategory, PhysicsTag, EvgenTag, SimuTag, RecoTag,
0021     Dataset, ProdConfig, ProdTask,
0022 )
0023 from .schemas import TAG_SCHEMAS, get_tag_model, get_param_defs, save_param_defs
0024 from .forms import PhysicsTagForm, SimpleTagForm, DatasetForm, PhysicsCategoryForm, ProdConfigForm
0025 
0026 
0027 def pcs_hub_counts():
0028     """PCS entity counts — shared by PCS hub and production hub."""
0029     return {
0030         'categories_count': PhysicsCategory.objects.count(),
0031         'physics_tags_count': PhysicsTag.objects.count(),
0032         'evgen_tags_count': EvgenTag.objects.count(),
0033         'simu_tags_count': SimuTag.objects.count(),
0034         'reco_tags_count': RecoTag.objects.count(),
0035         'datasets_count': Dataset.objects.values('dataset_name').distinct().count(),
0036         'prod_configs_count': ProdConfig.objects.count(),
0037         'prod_tasks_count': ProdTask.objects.count(),
0038     }
0039 
0040 
0041 def pcs_hub(request):
0042     return render(request, 'pcs/pcs_hub.html', pcs_hub_counts())
0043 
0044 
0045 # ── Physics Categories ────────────────────────────────────────────
0046 
0047 def physics_categories_list(request):
0048     categories = PhysicsCategory.objects.annotate(tag_count=Count('tags'))
0049     return render(request, 'pcs/physics_categories_list.html', {'categories': categories})
0050 
0051 
0052 @login_required
0053 def physics_category_create(request):
0054     if request.method == 'POST':
0055         form = PhysicsCategoryForm(request.POST)
0056         if form.is_valid():
0057             form.save()
0058             messages.success(request, f"Category {form.instance.digit}: {form.instance.name} created.")
0059             return redirect('pcs:physics_categories_list')
0060     else:
0061         form = PhysicsCategoryForm()
0062     return render(request, 'pcs/physics_category_create.html', {'form': form})
0063 
0064 
0065 # ── Tag list/detail/create (generic across p/e/s/r) ──────────────
0066 
0067 TAG_MODELS = {
0068     'p': PhysicsTag,
0069     'e': EvgenTag,
0070     's': SimuTag,
0071     'r': RecoTag,
0072 }
0073 
0074 
0075 def tags_list(request, tag_type):
0076     schema = TAG_SCHEMAS[tag_type]
0077     model = TAG_MODELS[tag_type]
0078 
0079     status_filter = request.GET.get('status', '')
0080     category_filter = request.GET.get('category', '')
0081 
0082     columns = [
0083         {'name': 'tag_label', 'title': 'Tag', 'orderable': True},
0084         {'name': 'description', 'title': 'Description', 'orderable': True},
0085         {'name': 'status', 'title': 'Status', 'orderable': True},
0086         {'name': 'created_by', 'title': 'Created By', 'orderable': True},
0087         {'name': 'created_at', 'title': 'Created', 'orderable': True},
0088         {'name': 'actions', 'title': '', 'orderable': False},
0089     ]
0090     if tag_type == 'p':
0091         columns.insert(1, {'name': 'category__name', 'title': 'Category', 'orderable': True})
0092 
0093     statuses = ['draft', 'locked']
0094     categories = list(PhysicsCategory.objects.values_list('name', flat=True)) if tag_type == 'p' else []
0095 
0096     context = {
0097         'table_title': f'{schema["label"]} Tags',
0098         'table_description': f'All {schema["label"].lower()} tags registered in PCS.',
0099         'ajax_url': reverse('pcs:tags_datatable_ajax', args=[tag_type]),
0100         'columns': columns,
0101         'tag_type': tag_type,
0102         'schema': schema,
0103         'statuses': statuses,
0104         'categories': categories,
0105         'selected_status': status_filter,
0106         'selected_category': category_filter,
0107     }
0108     return render(request, 'pcs/tag_list.html', context)
0109 
0110 
0111 def tags_datatable_ajax(request, tag_type):
0112     model = TAG_MODELS[tag_type]
0113 
0114     if tag_type == 'p':
0115         col_names = ['tag_label', 'category__name', 'description', 'status', 'created_by', 'created_at', 'actions']
0116     else:
0117         col_names = ['tag_label', 'description', 'status', 'created_by', 'created_at', 'actions']
0118 
0119     dt = DataTablesProcessor(request, col_names, default_order_column=0, default_order_direction='desc')
0120 
0121     qs = model.objects.all()
0122     if tag_type == 'p':
0123         qs = qs.select_related('category')
0124 
0125     filters = get_filter_params(request, ['status', 'category'])
0126     if filters['status']:
0127         qs = qs.filter(status=filters['status'])
0128     if tag_type == 'p' and filters.get('category'):
0129         qs = qs.filter(category__name=filters['category'])
0130 
0131     records_total = model.objects.count()
0132     search_fields = ['tag_label', 'description', 'created_by']
0133     if tag_type == 'p':
0134         search_fields.append('category__name')
0135     qs = dt.apply_search(qs, search_fields)
0136     records_filtered = qs.count()
0137 
0138     qs = qs.order_by(dt.get_order_by())
0139     page = dt.apply_pagination(qs)
0140 
0141     data = []
0142     for tag in page:
0143         compose_url = reverse('pcs:tag_compose', args=[tag_type])
0144         tag_url = f'{compose_url}?selected={urlquote(tag.tag_label)}'
0145         tag_link = f'<a href="{tag_url}">{tag.tag_label}</a>'
0146         status_badge = (
0147             f'<span class="badge bg-secondary">{tag.status}</span>'
0148             if tag.status == 'draft'
0149             else f'<span class="badge bg-success">{tag.status}</span>'
0150         )
0151         row = [tag_link]
0152         if tag_type == 'p':
0153             row.append(tag.category.name)
0154         row += [
0155             tag.description[:80] + ('...' if len(tag.description) > 80 else ''),
0156             status_badge,
0157             tag.created_by,
0158             format_datetime(tag.created_at),
0159             f'<a href="{tag_url}">View</a>',
0160         ]
0161         data.append(row)
0162 
0163     return dt.create_response(data, records_total, records_filtered)
0164 
0165 
0166 def tag_detail(request, tag_type, tag_number):
0167     model = TAG_MODELS[tag_type]
0168     schema = TAG_SCHEMAS[tag_type]
0169     tag = get_object_or_404(model, tag_number=tag_number)
0170 
0171     datasets = []
0172     if tag.status == 'locked':
0173         filter_kwarg = {f'{schema["prefix"]}__tag_number' if schema["prefix"] == 'p' else f'{"physics" if schema["prefix"] == "p" else {"e": "evgen", "s": "simu", "r": "reco"}[schema["prefix"]]}_tag': tag}
0174         # Build the correct filter field name
0175         field_map = {'p': 'physics_tag', 'e': 'evgen_tag', 's': 'simu_tag', 'r': 'reco_tag'}
0176         datasets = Dataset.objects.filter(**{field_map[tag_type]: tag}).order_by('-created_at')
0177 
0178     defs = get_param_defs(tag_type)
0179     context = {
0180         'tag': tag,
0181         'tag_type': tag_type,
0182         'schema': schema,
0183         'datasets': datasets,
0184         'required_fields': [d['name'] for d in defs if d.get('required')],
0185         'optional_fields': [d['name'] for d in defs if not d.get('required')],
0186     }
0187     return render(request, 'pcs/tag_detail.html', context)
0188 
0189 
0190 @login_required
0191 def tag_create(request, tag_type):
0192     schema = TAG_SCHEMAS[tag_type]
0193 
0194     if tag_type == 'p':
0195         FormClass = PhysicsTagForm
0196         form_kwargs = {}
0197     else:
0198         FormClass = SimpleTagForm
0199         form_kwargs = {'tag_type': tag_type}
0200 
0201     if request.method == 'POST':
0202         form = FormClass(request.POST, **form_kwargs)
0203         if form.is_valid():
0204             model = TAG_MODELS[tag_type]
0205             params = form.get_parameters()
0206 
0207             if tag_type == 'p':
0208                 category = form.cleaned_data['category']
0209                 tag_number = PhysicsTag.allocate_next(category)
0210                 tag = PhysicsTag(
0211                     tag_number=tag_number,
0212                     category=category,
0213                     description=form.cleaned_data['description'],
0214                     parameters=params,
0215                     created_by=form.cleaned_data['created_by'],
0216                 )
0217             else:
0218                 tag_number = model.allocate_next()
0219                 tag = model(
0220                     tag_number=tag_number,
0221                     description=form.cleaned_data['description'],
0222                     parameters=params,
0223                     created_by=form.cleaned_data['created_by'],
0224                 )
0225             tag.save()
0226             messages.success(request, f"Tag {tag.tag_label} created.")
0227             compose_url = reverse('pcs:tag_compose', kwargs={'tag_type': tag_type})
0228             return redirect(f'{compose_url}?selected={urlquote(tag.tag_label)}')
0229     else:
0230         form = FormClass(**form_kwargs)
0231 
0232     context = {
0233         'form': form,
0234         'tag_type': tag_type,
0235         'schema': schema,
0236     }
0237     template = 'pcs/tag_create_physics.html' if tag_type == 'p' else 'pcs/tag_create.html'
0238     return render(request, template, context)
0239 
0240 
0241 def tag_compose(request, tag_type):
0242     """Split-panel browse + compose UI for physics tags."""
0243     schema = TAG_SCHEMAS[tag_type]
0244     model = TAG_MODELS[tag_type]
0245 
0246     if tag_type == 'p':
0247         FormClass = PhysicsTagForm
0248         form_kwargs = {}
0249     else:
0250         FormClass = SimpleTagForm
0251         form_kwargs = {'tag_type': tag_type}
0252 
0253     selected_tag = None
0254     if request.method == 'POST':
0255         if not request.user.is_authenticated:
0256             from django.contrib.auth.views import redirect_to_login
0257             return redirect_to_login(request.get_full_path())
0258         form = FormClass(request.POST, **form_kwargs)
0259         if form.is_valid():
0260             params = form.get_parameters()
0261             if tag_type == 'p':
0262                 category = form.cleaned_data['category']
0263                 tag_number = PhysicsTag.allocate_next(category)
0264                 tag = PhysicsTag(
0265                     tag_number=tag_number,
0266                     category=category,
0267                     description=form.cleaned_data['description'],
0268                     parameters=params,
0269                     created_by=form.cleaned_data['created_by'],
0270                 )
0271             else:
0272                 tag_number = model.allocate_next()
0273                 tag = model(
0274                     tag_number=tag_number,
0275                     description=form.cleaned_data['description'],
0276                     parameters=params,
0277                     created_by=form.cleaned_data['created_by'],
0278                 )
0279             tag.save()
0280             messages.success(request, f"Tag {tag.tag_label} created.")
0281             compose_url = reverse('pcs:tag_compose', kwargs={'tag_type': tag_type})
0282             return redirect(f'{compose_url}?selected={urlquote(tag.tag_label)}')
0283     else:
0284         form = FormClass(**form_kwargs)
0285         selected_tag = request.GET.get('selected')
0286 
0287     qs = model.objects.order_by('-tag_number')
0288     if tag_type == 'p':
0289         qs = qs.select_related('category')
0290     tags_data = []
0291     for t in qs:
0292         entry = {
0293             'tag_number': t.tag_number,
0294             'tag_label': t.tag_label,
0295             'status': t.status,
0296             'description': t.description,
0297             'parameters': t.parameters,
0298             'created_by': t.created_by,
0299             'created_at': t.created_at.strftime('%Y-%m-%d %H:%M'),
0300         }
0301         if tag_type == 'p':
0302             entry['category_digit'] = t.category.digit
0303             entry['category_name'] = t.category.name
0304         tags_data.append(entry)
0305 
0306     param_defs = get_param_defs(tag_type)
0307     choices_from_defs = {d['name']: d['choices'] for d in param_defs if d.get('choices')}
0308     filter_fields = [d['name'] for d in param_defs
0309                      if d['name'] not in ('notes', 'description')]
0310 
0311     # Peek at next tag suffix from PersistentState (read-only, no increment)
0312     from monitor_app.models import PersistentState
0313     state_keys = {'p': 'pcs_next_physics', 'e': 'pcs_next_evgen',
0314                   's': 'pcs_next_simu', 'r': 'pcs_next_reco'}
0315     try:
0316         ps = PersistentState.objects.get(id=1)
0317         next_suffix = ps.state_data.get(state_keys[tag_type], 1)
0318     except PersistentState.DoesNotExist:
0319         next_suffix = 1
0320 
0321     context = {
0322         'form': form,
0323         'tag_type': tag_type,
0324         'schema': schema,
0325         'tags_json': json.dumps(tags_data, default=str),
0326         'choices_json': json.dumps(choices_from_defs),
0327         'filter_fields_json': json.dumps(filter_fields),
0328         'param_defs_json': json.dumps(param_defs),
0329         'next_suffix': next_suffix,
0330         'username': request.user.username if request.user.is_authenticated else '',
0331         'selected_tag_json': json.dumps(selected_tag),
0332     }
0333     return render(request, 'pcs/tag_compose.html', context)
0334 
0335 
0336 def param_defs_api(request, tag_type):
0337     if tag_type not in TAG_SCHEMAS:
0338         return JsonResponse({'error': 'Invalid tag type'}, status=400)
0339     if request.method == 'GET':
0340         return JsonResponse({'defs': get_param_defs(tag_type)})
0341     if not request.user.is_authenticated:
0342         return JsonResponse({'error': 'Login required'}, status=403)
0343     if request.method == 'POST':
0344         try:
0345             data = json.loads(request.body)
0346         except json.JSONDecodeError:
0347             return JsonResponse({'error': 'Invalid JSON'}, status=400)
0348         defs = data.get('defs')
0349         if not isinstance(defs, list):
0350             return JsonResponse({'error': 'defs must be a list'}, status=400)
0351         names_seen = set()
0352         for i, d in enumerate(defs):
0353             if not isinstance(d, dict) or not d.get('name'):
0354                 return JsonResponse({'error': f'Invalid param def at index {i}'}, status=400)
0355             name = d['name'].strip()
0356             if name in names_seen:
0357                 return JsonResponse({'error': f'Duplicate param name: {name}'}, status=400)
0358             names_seen.add(name)
0359             d['name'] = name
0360             d.setdefault('type', 'string')
0361             d.setdefault('required', False)
0362             d.setdefault('choices', [])
0363             d.setdefault('allow_other', True)
0364             d['sort_order'] = i
0365         save_param_defs(tag_type, defs)
0366         return JsonResponse({'ok': True, 'defs': defs})
0367     return JsonResponse({'error': 'Method not allowed'}, status=405)
0368 
0369 
0370 @login_required
0371 def tag_delete(request, tag_type, tag_number):
0372     if request.method != 'POST':
0373         return redirect('pcs:tag_compose', tag_type=tag_type)
0374     model = TAG_MODELS[tag_type]
0375     tag = get_object_or_404(model, tag_number=tag_number)
0376     if tag.status == 'locked':
0377         messages.error(request, f"Tag {tag.tag_label} is locked and cannot be deleted.")
0378         return redirect('pcs:tag_compose', tag_type=tag_type)
0379     if tag.created_by != request.user.username:
0380         messages.error(request, f"Only the creator ({tag.created_by}) can delete {tag.tag_label}.")
0381         return redirect('pcs:tag_compose', tag_type=tag_type)
0382     label = tag.tag_label
0383     tag.delete()
0384     messages.success(request, f"Tag {label} deleted.")
0385     return redirect('pcs:tag_compose', tag_type=tag_type)
0386 
0387 
0388 @login_required
0389 def tag_lock(request, tag_type, tag_number):
0390     compose_url = reverse('pcs:tag_compose', kwargs={'tag_type': tag_type})
0391     selected_url = f'{compose_url}?selected={tag_number}'
0392     if request.method != 'POST':
0393         return redirect(selected_url)
0394     model = TAG_MODELS[tag_type]
0395     tag = get_object_or_404(model, tag_number=tag_number)
0396     if tag.created_by != request.user.username:
0397         messages.error(request, f"Only the creator ({tag.created_by}) can lock this tag.")
0398     elif tag.status == 'locked':
0399         messages.warning(request, f"Tag {tag.tag_label} is already locked.")
0400     else:
0401         tag.status = 'locked'
0402         tag.save(update_fields=['status', 'updated_at'])
0403         messages.success(request, f"Tag {tag.tag_label} locked. It can now be used in datasets.")
0404     return redirect(selected_url)
0405 
0406 
0407 @login_required
0408 def tag_edit(request, tag_type, tag_number):
0409     model = TAG_MODELS[tag_type]
0410     schema = TAG_SCHEMAS[tag_type]
0411     tag = get_object_or_404(model, tag_number=tag_number)
0412 
0413     compose_url = reverse('pcs:tag_compose', kwargs={'tag_type': tag_type})
0414     selected_url = f'{compose_url}?selected={tag_number}'
0415     if tag.status == 'locked':
0416         messages.error(request, f"Tag {tag.tag_label} is locked and cannot be edited.")
0417         return redirect(selected_url)
0418 
0419     if tag_type == 'p':
0420         FormClass = PhysicsTagForm
0421         form_kwargs = {}
0422     else:
0423         FormClass = SimpleTagForm
0424         form_kwargs = {'tag_type': tag_type}
0425 
0426     if request.method == 'POST':
0427         form = FormClass(request.POST, **form_kwargs)
0428         if form.is_valid():
0429             tag.description = form.cleaned_data['description']
0430             tag.parameters = form.get_parameters()
0431             if tag_type == 'p':
0432                 tag.category = form.cleaned_data['category']
0433             tag.save()
0434             messages.success(request, f"Tag {tag.tag_label} updated.")
0435             return redirect(selected_url)
0436     else:
0437         initial = {
0438             'description': tag.description,
0439             'created_by': tag.created_by,
0440         }
0441         if tag_type == 'p':
0442             initial['category'] = tag.category
0443         for k, v in tag.parameters.items():
0444             initial[f'param_{k}'] = v
0445         form = FormClass(initial=initial, **form_kwargs)
0446 
0447     context = {
0448         'form': form,
0449         'tag': tag,
0450         'tag_type': tag_type,
0451         'schema': schema,
0452         'editing': True,
0453     }
0454     template = 'pcs/tag_create_physics.html' if tag_type == 'p' else 'pcs/tag_create.html'
0455     return render(request, template, context)
0456 
0457 
0458 # ── Datasets ──────────────────────────────────────────────────────
0459 
0460 def datasets_compose(request):
0461     """Two-pane browse/create UI for datasets."""
0462     if request.method == 'POST' and request.user.is_authenticated:
0463         form = DatasetForm(request.POST)
0464         if form.is_valid():
0465             cd = form.cleaned_data
0466             ds = Dataset(
0467                 scope=cd['scope'],
0468                 detector_version=cd['detector_version'],
0469                 detector_config=cd['detector_config'],
0470                 physics_tag=cd['physics_tag'],
0471                 evgen_tag=cd['evgen_tag'],
0472                 simu_tag=cd['simu_tag'],
0473                 reco_tag=cd['reco_tag'],
0474                 description=cd.get('description', ''),
0475                 created_by=cd['created_by'],
0476             )
0477             ds.save()
0478             messages.success(request, f"Dataset created: {ds.did}")
0479             return redirect(f"{reverse('pcs:datasets_compose')}?selected={urlquote(ds.dataset_name)}")
0480 
0481     qs = Dataset.objects.filter(block_num=1).select_related(
0482         'physics_tag', 'evgen_tag', 'simu_tag', 'reco_tag',
0483     ).order_by('-created_at')
0484     datasets_data = []
0485     for ds in qs:
0486         datasets_data.append({
0487             'id': ds.id,
0488             'dataset_name': ds.dataset_name,
0489             'did': ds.did,
0490             'scope': ds.scope,
0491             'detector_version': ds.detector_version,
0492             'detector_config': ds.detector_config,
0493             'description': ds.description,
0494             'blocks': ds.blocks,
0495             'created_by': ds.created_by,
0496             'created_at': ds.created_at.strftime('%Y-%m-%d %H:%M'),
0497             'physics_tag': {'id': ds.physics_tag_id, 'label': ds.physics_tag.tag_label,
0498                             'description': ds.physics_tag.description, 'parameters': ds.physics_tag.parameters},
0499             'evgen_tag': {'id': ds.evgen_tag_id, 'label': ds.evgen_tag.tag_label,
0500                           'description': ds.evgen_tag.description, 'parameters': ds.evgen_tag.parameters},
0501             'simu_tag': {'id': ds.simu_tag_id, 'label': ds.simu_tag.tag_label,
0502                          'description': ds.simu_tag.description, 'parameters': ds.simu_tag.parameters},
0503             'reco_tag': {'id': ds.reco_tag_id, 'label': ds.reco_tag.tag_label,
0504                          'description': ds.reco_tag.description, 'parameters': ds.reco_tag.parameters},
0505         })
0506 
0507     # Full tag data for browsing and diffs
0508     tags_data = {}
0509     for ttype, model in TAG_MODELS_MAP.items():
0510         tag_list = []
0511         qs_tags = model.objects.order_by('tag_number')
0512         if ttype == 'p':
0513             qs_tags = qs_tags.select_related('category')
0514         for t in qs_tags:
0515             entry = {'id': t.id, 'tag_number': t.tag_number, 'label': t.tag_label,
0516                      'description': t.description, 'status': t.status,
0517                      'parameters': t.parameters, 'created_by': t.created_by}
0518             if ttype == 'p':
0519                 entry['category_name'] = t.category.name
0520             tag_list.append(entry)
0521         tags_data[ttype] = tag_list
0522 
0523     context = {
0524         'datasets_json': json.dumps(datasets_data),
0525         'tags_json': json.dumps(tags_data),
0526         'selected_item_json': json.dumps(request.GET.get('selected') or None),
0527         'username': request.user.username if request.user.is_authenticated else '',
0528     }
0529     return render(request, 'pcs/dataset_compose.html', context)
0530 
0531 
0532 def datasets_list(request):
0533     columns = [
0534         {'name': 'dataset_name', 'title': 'Dataset Name', 'orderable': True},
0535         {'name': 'physics_tag__tag_label', 'title': 'Physics', 'orderable': True},
0536         {'name': 'evgen_tag__tag_label', 'title': 'EvGen', 'orderable': True},
0537         {'name': 'simu_tag__tag_label', 'title': 'Simu', 'orderable': True},
0538         {'name': 'reco_tag__tag_label', 'title': 'Reco', 'orderable': True},
0539         {'name': 'blocks', 'title': 'Blocks', 'orderable': True},
0540         {'name': 'created_at', 'title': 'Created', 'orderable': True},
0541     ]
0542     context = {
0543         'table_title': 'Datasets',
0544         'table_description': 'All datasets registered in PCS.',
0545         'ajax_url': reverse('pcs:datasets_datatable_ajax'),
0546         'columns': columns,
0547     }
0548     return render(request, 'pcs/datasets_list.html', context)
0549 
0550 
0551 def datasets_datatable_ajax(request):
0552     col_names = [
0553         'dataset_name', 'physics_tag__tag_label', 'evgen_tag__tag_label',
0554         'simu_tag__tag_label', 'reco_tag__tag_label', 'blocks', 'created_at',
0555     ]
0556     dt = DataTablesProcessor(request, col_names, default_order_column=6, default_order_direction='desc')
0557 
0558     # Only show block 1 rows (one row per logical dataset)
0559     qs = Dataset.objects.filter(block_num=1).select_related(
0560         'physics_tag', 'evgen_tag', 'simu_tag', 'reco_tag'
0561     )
0562 
0563     records_total = Dataset.objects.filter(block_num=1).count()
0564     search_fields = ['dataset_name', 'physics_tag__tag_label', 'evgen_tag__tag_label',
0565                      'simu_tag__tag_label', 'reco_tag__tag_label']
0566     qs = dt.apply_search(qs, search_fields)
0567     records_filtered = qs.count()
0568     qs = qs.order_by(dt.get_order_by())
0569     page = dt.apply_pagination(qs)
0570 
0571     data = []
0572     for ds in page:
0573         detail_url = reverse('pcs:dataset_detail', args=[ds.id])
0574         p_url = f"{reverse('pcs:tag_compose', args=['p'])}?selected={ds.physics_tag.tag_number}"
0575         e_url = f"{reverse('pcs:tag_compose', args=['e'])}?selected={ds.evgen_tag.tag_number}"
0576         s_url = f"{reverse('pcs:tag_compose', args=['s'])}?selected={ds.simu_tag.tag_number}"
0577         r_url = f"{reverse('pcs:tag_compose', args=['r'])}?selected={ds.reco_tag.tag_number}"
0578         data.append([
0579             f'<a href="{detail_url}">{ds.dataset_name}</a>',
0580             f'<a href="{p_url}" title="{ds.physics_tag.description}">{ds.physics_tag.tag_label}</a>',
0581             f'<a href="{e_url}" title="{ds.evgen_tag.description}">{ds.evgen_tag.tag_label}</a>',
0582             f'<a href="{s_url}" title="{ds.simu_tag.description}">{ds.simu_tag.tag_label}</a>',
0583             f'<a href="{r_url}" title="{ds.reco_tag.description}">{ds.reco_tag.tag_label}</a>',
0584             str(ds.blocks),
0585             format_datetime(ds.created_at),
0586         ])
0587 
0588     return dt.create_response(data, records_total, records_filtered)
0589 
0590 
0591 def dataset_detail(request, pk):
0592     dataset = get_object_or_404(
0593         Dataset.objects.select_related('physics_tag', 'evgen_tag', 'simu_tag', 'reco_tag'),
0594         pk=pk,
0595     )
0596     blocks = Dataset.objects.filter(dataset_name=dataset.dataset_name).order_by('block_num')
0597     context = {
0598         'dataset': dataset,
0599         'blocks': blocks,
0600     }
0601     return render(request, 'pcs/dataset_detail.html', context)
0602 
0603 
0604 @login_required
0605 def dataset_create(request):
0606     if request.method == 'POST':
0607         form = DatasetForm(request.POST)
0608         if form.is_valid():
0609             cd = form.cleaned_data
0610             ds = Dataset(
0611                 scope=cd['scope'],
0612                 detector_version=cd['detector_version'],
0613                 detector_config=cd['detector_config'],
0614                 physics_tag=cd['physics_tag'],
0615                 evgen_tag=cd['evgen_tag'],
0616                 simu_tag=cd['simu_tag'],
0617                 reco_tag=cd['reco_tag'],
0618                 description=cd.get('description', ''),
0619                 created_by=cd['created_by'],
0620             )
0621             ds.save()
0622             messages.success(request, f"Dataset created: {ds.did}")
0623             return redirect('pcs:dataset_detail', pk=ds.pk)
0624     else:
0625         form = DatasetForm()
0626     return render(request, 'pcs/dataset_create.html', {'form': form})
0627 
0628 
0629 @login_required
0630 def dataset_add_block(request, pk):
0631     if request.method != 'POST':
0632         return redirect('pcs:dataset_detail', pk=pk)
0633     dataset = get_object_or_404(Dataset, pk=pk)
0634     new_block_num = dataset.blocks + 1
0635     Dataset.objects.filter(dataset_name=dataset.dataset_name).update(blocks=new_block_num)
0636     new_block = Dataset.objects.create(
0637         dataset_name=dataset.dataset_name,
0638         scope=dataset.scope,
0639         detector_version=dataset.detector_version,
0640         detector_config=dataset.detector_config,
0641         physics_tag=dataset.physics_tag,
0642         evgen_tag=dataset.evgen_tag,
0643         simu_tag=dataset.simu_tag,
0644         reco_tag=dataset.reco_tag,
0645         block_num=new_block_num,
0646         blocks=new_block_num,
0647         did=f"{dataset.scope}:{dataset.dataset_name}.b{new_block_num}",
0648         description=dataset.description,
0649         metadata=dataset.metadata,
0650         created_by=request.user.username if request.user.is_authenticated else 'unknown',
0651     )
0652     messages.success(request, f"Block {new_block_num} added: {new_block.did}")
0653     return redirect('pcs:dataset_detail', pk=dataset.pk)
0654 
0655 
0656 # ── Production Configs ────────────────────────────────────────────
0657 
0658 def prod_configs_compose(request):
0659     """Two-pane browse/create/edit UI for production configs."""
0660     if request.method == 'POST' and request.user.is_authenticated:
0661         editing_pk = request.POST.get('editing_pk')
0662         if editing_pk:
0663             instance = get_object_or_404(ProdConfig, pk=editing_pk)
0664             form = ProdConfigForm(request.POST, instance=instance)
0665         else:
0666             form = ProdConfigForm(request.POST)
0667         if form.is_valid():
0668             pc = form.save()
0669             messages.success(request, f"Config '{pc.name}' {'updated' if editing_pk else 'created'}.")
0670             return redirect(f"{reverse('pcs:prod_configs_compose')}?selected={urlquote(pc.name)}")
0671 
0672     qs = ProdConfig.objects.order_by('-updated_at')
0673     configs_data = []
0674     for pc in qs:
0675         configs_data.append({
0676             'id': pc.id,
0677             'name': pc.name,
0678             'description': pc.description,
0679             'bg_mixing': pc.bg_mixing,
0680             'bg_cross_section': pc.bg_cross_section,
0681             'bg_evtgen_file': pc.bg_evtgen_file,
0682             'copy_reco': pc.copy_reco,
0683             'copy_full': pc.copy_full,
0684             'copy_log': pc.copy_log,
0685             'use_rucio': pc.use_rucio,
0686             'jug_xl_tag': pc.jug_xl_tag,
0687             'container_image': pc.container_image,
0688             'target_hours_per_job': str(pc.target_hours_per_job) if pc.target_hours_per_job else '',
0689             'events_per_task': pc.events_per_task,
0690             'panda_site': pc.panda_site,
0691             'panda_queue': pc.panda_queue,
0692             'panda_working_group': pc.panda_working_group,
0693             'panda_resource_type': pc.panda_resource_type,
0694             'rucio_rse': pc.rucio_rse,
0695             'rucio_replication_rules': pc.rucio_replication_rules,
0696             'condor_template': pc.condor_template,
0697             'data': pc.data or {},
0698             'created_by': pc.created_by,
0699             'created_at': pc.created_at.strftime('%Y-%m-%d %H:%M'),
0700             'updated_at': pc.updated_at.strftime('%Y-%m-%d %H:%M'),
0701         })
0702 
0703     context = {
0704         'configs_json': json.dumps(configs_data),
0705         'selected_item_json': json.dumps(request.GET.get('selected') or None),
0706         'username': request.user.username if request.user.is_authenticated else '',
0707     }
0708     return render(request, 'pcs/prod_config_compose.html', context)
0709 
0710 
0711 def prod_configs_list(request):
0712     columns = [
0713         {'name': 'name', 'title': 'Name', 'orderable': True},
0714         {'name': 'description', 'title': 'Description', 'orderable': True},
0715         {'name': 'jug_xl_tag', 'title': 'JUG_XL', 'orderable': True},
0716         {'name': 'target_hours_per_job', 'title': 'Hours/Job', 'orderable': True},
0717         {'name': 'events_per_task', 'title': 'Events/Task', 'orderable': True},
0718         {'name': 'created_by', 'title': 'Created By', 'orderable': True},
0719         {'name': 'updated_at', 'title': 'Updated', 'orderable': True},
0720     ]
0721     context = {
0722         'table_title': 'Production Configs',
0723         'table_description': 'Reusable production configuration templates for job submission.',
0724         'ajax_url': reverse('pcs:prod_configs_datatable_ajax'),
0725         'columns': columns,
0726     }
0727     return render(request, 'pcs/prod_configs_list.html', context)
0728 
0729 
0730 def prod_configs_datatable_ajax(request):
0731     col_names = ['name', 'description', 'jug_xl_tag', 'target_hours_per_job',
0732                  'events_per_task', 'created_by', 'updated_at']
0733     dt = DataTablesProcessor(request, col_names, default_order_column=6, default_order_direction='desc')
0734 
0735     qs = ProdConfig.objects.all()
0736     records_total = qs.count()
0737     search_fields = ['name', 'description', 'created_by', 'jug_xl_tag']
0738     qs = dt.apply_search(qs, search_fields)
0739     records_filtered = qs.count()
0740     qs = qs.order_by(dt.get_order_by())
0741     page = dt.apply_pagination(qs)
0742 
0743     data = []
0744     for pc in page:
0745         detail_url = reverse('pcs:prod_config_detail', args=[pc.pk])
0746         data.append([
0747             f'<a href="{detail_url}">{pc.name}</a>',
0748             pc.description[:80] + ('...' if len(pc.description) > 80 else ''),
0749             pc.jug_xl_tag or '-',
0750             str(pc.target_hours_per_job) if pc.target_hours_per_job else '-',
0751             str(pc.events_per_task) if pc.events_per_task else '-',
0752             pc.created_by,
0753             format_datetime(pc.updated_at),
0754         ])
0755 
0756     return dt.create_response(data, records_total, records_filtered)
0757 
0758 
0759 def prod_config_detail(request, pk):
0760     config = get_object_or_404(ProdConfig, pk=pk)
0761     return render(request, 'pcs/prod_config_detail.html', {'config': config})
0762 
0763 
0764 @login_required
0765 def prod_config_create(request):
0766     if request.method == 'POST':
0767         form = ProdConfigForm(request.POST)
0768         if form.is_valid():
0769             form.save()
0770             messages.success(request, f"Production config '{form.instance.name}' created.")
0771             return redirect('pcs:prod_config_detail', pk=form.instance.pk)
0772     else:
0773         form = ProdConfigForm()
0774     return render(request, 'pcs/prod_config_form.html', {'form': form})
0775 
0776 
0777 @login_required
0778 def prod_config_edit(request, pk):
0779     config = get_object_or_404(ProdConfig, pk=pk)
0780     if request.method == 'POST':
0781         form = ProdConfigForm(request.POST, instance=config)
0782         if form.is_valid():
0783             form.save()
0784             messages.success(request, f"Production config '{config.name}' updated.")
0785             return redirect('pcs:prod_config_detail', pk=config.pk)
0786     else:
0787         form = ProdConfigForm(instance=config)
0788     return render(request, 'pcs/prod_config_form.html', {'form': form, 'editing': True, 'config': config})
0789 
0790 
0791 # ── Production Tasks ─────────────────────────────────────────────
0792 
0793 TAG_MODELS_MAP = {'p': PhysicsTag, 'e': EvgenTag, 's': SimuTag, 'r': RecoTag}
0794 
0795 
0796 def prod_tasks_list(request):
0797     columns = [
0798         {'name': 'name', 'title': 'Name', 'orderable': True},
0799         {'name': 'status', 'title': 'Status', 'orderable': True},
0800         {'name': 'dataset__dataset_name', 'title': 'Dataset', 'orderable': True},
0801         {'name': 'prod_config__name', 'title': 'Config', 'orderable': True},
0802         {'name': 'created_by', 'title': 'Created By', 'orderable': True},
0803         {'name': 'updated_at', 'title': 'Updated', 'orderable': True},
0804     ]
0805     context = {
0806         'table_title': 'Production Tasks',
0807         'table_description': 'Production task compositions (Dataset + Config).',
0808         'ajax_url': reverse('pcs:prod_tasks_datatable_ajax'),
0809         'columns': columns,
0810     }
0811     return render(request, 'pcs/prod_tasks_list.html', context)
0812 
0813 
0814 def prod_tasks_datatable_ajax(request):
0815     col_names = ['name', 'status', 'dataset__dataset_name', 'prod_config__name',
0816                  'created_by', 'updated_at']
0817     dt = DataTablesProcessor(request, col_names, default_order_column=5, default_order_direction='desc')
0818 
0819     qs = ProdTask.objects.select_related('dataset', 'prod_config')
0820     records_total = qs.count()
0821     search_fields = ['name', 'description', 'dataset__dataset_name', 'prod_config__name', 'created_by']
0822     qs = dt.apply_search(qs, search_fields)
0823     records_filtered = qs.count()
0824     qs = qs.order_by(dt.get_order_by())
0825     page = dt.apply_pagination(qs)
0826 
0827     status_colors = {'draft': 'secondary', 'ready': 'primary', 'submitted': 'info',
0828                      'completed': 'success', 'failed': 'danger'}
0829     data = []
0830     for t in page:
0831         detail_url = reverse('pcs:prod_task_detail', args=[t.pk])
0832         color = status_colors.get(t.status, 'secondary')
0833         data.append([
0834             f'<a href="{detail_url}">{t.name}</a>',
0835             f'<span class="badge bg-{color}">{t.status}</span>',
0836             t.dataset.dataset_name,
0837             t.prod_config.name,
0838             t.created_by,
0839             format_datetime(t.updated_at),
0840         ])
0841 
0842     return dt.create_response(data, records_total, records_filtered)
0843 
0844 
0845 def prod_task_detail(request, pk):
0846     from .commands import build_task_params
0847     task = get_object_or_404(
0848         ProdTask.objects.select_related(
0849             'dataset', 'dataset__physics_tag', 'dataset__evgen_tag',
0850             'dataset__simu_tag', 'dataset__reco_tag', 'prod_config',
0851         ),
0852         pk=pk,
0853     )
0854     try:
0855         task_params = build_task_params(task)
0856         task_params_json = json.dumps(task_params, indent=2, sort_keys=False, default=str)
0857         task_params_error = None
0858     except Exception as e:
0859         task_params_json = None
0860         task_params_error = str(e)
0861     return render(request, 'pcs/prod_task_detail.html', {
0862         'task': task,
0863         'task_params_json': task_params_json,
0864         'task_params_error': task_params_error,
0865     })
0866 
0867 
0868 def prod_task_compose(request):
0869     """Two-pane compose UI for building production tasks."""
0870     from .commands import build_task_params
0871     # Preload all component data as JSON for client-side browsing
0872     datasets_qs = Dataset.objects.filter(block_num=1).select_related(
0873         'physics_tag', 'evgen_tag', 'simu_tag', 'reco_tag',
0874     ).order_by('-created_at')
0875     datasets_data = []
0876     for ds in datasets_qs:
0877         datasets_data.append({
0878             'id': ds.id,
0879             'dataset_name': ds.dataset_name,
0880             'did': ds.did,
0881             'scope': ds.scope,
0882             'detector_version': ds.detector_version,
0883             'detector_config': ds.detector_config,
0884             'physics_tag': {'label': ds.physics_tag.tag_label, 'description': ds.physics_tag.description,
0885                             'parameters': ds.physics_tag.parameters},
0886             'evgen_tag': {'label': ds.evgen_tag.tag_label, 'description': ds.evgen_tag.description,
0887                           'parameters': ds.evgen_tag.parameters},
0888             'simu_tag': {'label': ds.simu_tag.tag_label, 'description': ds.simu_tag.description,
0889                          'parameters': ds.simu_tag.parameters},
0890             'reco_tag': {'label': ds.reco_tag.tag_label, 'description': ds.reco_tag.description,
0891                          'parameters': ds.reco_tag.parameters},
0892             'created_by': ds.created_by,
0893             'created_at': ds.created_at.strftime('%Y-%m-%d %H:%M'),
0894         })
0895 
0896     configs_qs = ProdConfig.objects.order_by('-updated_at')
0897     configs_data = []
0898     for pc in configs_qs:
0899         configs_data.append({
0900             'id': pc.id,
0901             'name': pc.name,
0902             'description': pc.description,
0903             'jug_xl_tag': pc.jug_xl_tag,
0904             'container_image': pc.container_image,
0905             'bg_mixing': pc.bg_mixing,
0906             'bg_cross_section': pc.bg_cross_section,
0907             'bg_evtgen_file': pc.bg_evtgen_file,
0908             'copy_reco': pc.copy_reco,
0909             'copy_full': pc.copy_full,
0910             'copy_log': pc.copy_log,
0911             'use_rucio': pc.use_rucio,
0912             'target_hours_per_job': str(pc.target_hours_per_job) if pc.target_hours_per_job else '',
0913             'events_per_task': pc.events_per_task,
0914             'panda_site': pc.panda_site,
0915             'panda_queue': pc.panda_queue,
0916             'panda_working_group': pc.panda_working_group,
0917             'panda_resource_type': pc.panda_resource_type,
0918             'rucio_rse': pc.rucio_rse,
0919             'data': pc.data or {},
0920             'created_by': pc.created_by,
0921             'updated_at': pc.updated_at.strftime('%Y-%m-%d %H:%M'),
0922         })
0923 
0924     tasks_qs = ProdTask.objects.select_related(
0925         'dataset', 'dataset__physics_tag', 'dataset__evgen_tag',
0926         'dataset__simu_tag', 'dataset__reco_tag', 'prod_config',
0927     ).order_by('-updated_at')
0928     tasks_data = []
0929     for t in tasks_qs:
0930         try:
0931             task_params = build_task_params(t)
0932             task_params_json = json.dumps(task_params, indent=2, default=str)
0933         except Exception as e:
0934             task_params_json = f'// Error building taskParamMap: {e}'
0935         tasks_data.append({
0936             'id': t.id,
0937             'name': t.name,
0938             'status': t.status,
0939             'dataset_id': t.dataset_id,
0940             'dataset_name': t.dataset.dataset_name,
0941             'prod_config_id': t.prod_config_id,
0942             'prod_config_name': t.prod_config.name,
0943             'csv_file': t.csv_file,
0944             'overrides': t.overrides or {},
0945             'description': t.description,
0946             'condor_command': t.condor_command,
0947             'panda_command': t.panda_command,
0948             'task_params_json': task_params_json,
0949             'created_by': t.created_by,
0950             'updated_at': t.updated_at.strftime('%Y-%m-%d %H:%M'),
0951         })
0952 
0953     context = {
0954         'datasets_json': json.dumps(datasets_data),
0955         'configs_json': json.dumps(configs_data),
0956         'tasks_json': json.dumps(tasks_data),
0957         'selected_item_json': json.dumps(request.GET.get('selected') or None),
0958         'username': request.user.username if request.user.is_authenticated else '',
0959     }
0960     return render(request, 'pcs/prod_task_compose.html', context)
0961 
0962 
0963 @login_required
0964 def prod_task_delete(request, pk):
0965     if request.method != 'POST':
0966         return redirect('pcs:prod_task_detail', pk=pk)
0967     task = get_object_or_404(ProdTask, pk=pk)
0968     if task.status != 'draft':
0969         messages.error(request, "Only draft tasks can be deleted.")
0970         return redirect('pcs:prod_task_detail', pk=pk)
0971     task.delete()
0972     messages.success(request, f"Task '{task.name}' deleted.")
0973     return redirect('pcs:prod_tasks_list')
0974 
0975 
0976 def prod_task_generate_commands(request, pk):
0977     """JSON endpoint: regenerate and return commands for a ProdTask."""
0978     task = get_object_or_404(
0979         ProdTask.objects.select_related(
0980             'dataset', 'dataset__physics_tag', 'dataset__evgen_tag',
0981             'dataset__simu_tag', 'dataset__reco_tag', 'prod_config',
0982         ),
0983         pk=pk,
0984     )
0985     task.generate_commands()
0986     task.save(update_fields=['condor_command', 'panda_command', 'updated_at'])
0987     return JsonResponse({
0988         'condor_command': task.condor_command,
0989         'panda_command': task.panda_command,
0990     })