diff --git a/src/apps/users/models.py b/src/apps/users/models.py
index 58145c8..d789672 100644
--- a/src/apps/users/models.py
+++ b/src/apps/users/models.py
@@ -20,6 +20,11 @@ class User(AbstractUser):
objects = UserManager()
+ def __str__(self) -> str:
+ if self.first_name and self.last_name:
+ return f"{self.first_name} {self.last_name}"
+ return self.email
+
def get_absolute_url(self: Self) -> str:
"""Get URL for user's detail view.
diff --git a/src/config/settings/base.py b/src/config/settings/base.py
index 2e08ee3..70046f7 100644
--- a/src/config/settings/base.py
+++ b/src/config/settings/base.py
@@ -118,6 +118,7 @@
"genlab_bestilling",
"theme",
"frontend",
+ "nina",
]
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
INSTALLED_APPS = DJANGO_APPS + ADMIN_APPS + THIRD_PARTY_APPS + LOCAL_APPS
diff --git a/src/genlab_bestilling/admin.py b/src/genlab_bestilling/admin.py
index 4a1cde8..6b388c9 100644
--- a/src/genlab_bestilling/admin.py
+++ b/src/genlab_bestilling/admin.py
@@ -71,8 +71,7 @@ class LocationAdmin(ModelAdmin):
class GenrequestAdmin(ModelAdmin):
list_display = [
"name",
- "number",
- "verified",
+ "project",
"samples_owner",
"area",
"analysis_timerange",
diff --git a/src/genlab_bestilling/api/serializers.py b/src/genlab_bestilling/api/serializers.py
index edd4101..097e770 100644
--- a/src/genlab_bestilling/api/serializers.py
+++ b/src/genlab_bestilling/api/serializers.py
@@ -176,7 +176,7 @@ class Meta:
model = Genrequest
fields = (
"id",
- "number",
+ "project",
"area",
)
diff --git a/src/genlab_bestilling/forms.py b/src/genlab_bestilling/forms.py
index c4dfbc7..4fe2651 100644
--- a/src/genlab_bestilling/forms.py
+++ b/src/genlab_bestilling/forms.py
@@ -9,6 +9,7 @@
from formset.renderers.tailwind import FormRenderer
from formset.utils import FormMixin
from formset.widgets import DateInput, DualSortableSelector, Selectize
+from nina.models import Project
from .libs.formset import ContextFormCollection
from .models import (
@@ -28,6 +29,10 @@ def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
self.user = user
+ self.fields["project"].queryset = Project.objects.filter(
+ memberships=user,
+ )
+
def save(self, commit=True):
obj = super().save(commit=False)
if self.user:
@@ -40,10 +45,11 @@ def save(self, commit=True):
class Meta:
model = Genrequest
fields = (
- "number",
+ "project",
"name",
"area",
"species",
+ "samples_owner",
"sample_types",
"analysis_types",
"expected_total_samples",
@@ -51,6 +57,8 @@ class Meta:
)
widgets = {
"area": Selectize(search_lookup="name_icontains"),
+ "samples_owner": Selectize(search_lookup="name_icontains"),
+ "project": Selectize(search_lookup="number_istartswith"),
"species": DualSortableSelector(
search_lookup="name_icontains",
filter_by={"area": "area__id"},
diff --git a/src/genlab_bestilling/migrations/0001_initial.py b/src/genlab_bestilling/migrations/0001_initial.py
index 5b14565..7c19c75 100644
--- a/src/genlab_bestilling/migrations/0001_initial.py
+++ b/src/genlab_bestilling/migrations/0001_initial.py
@@ -1,4 +1,4 @@
-# Generated by Django 5.0.7 on 2024-07-24 14:16
+# Generated by Django 5.0.7 on 2024-07-25 08:32
import django.contrib.postgres.fields.ranges
import django.db.models.deletion
@@ -12,6 +12,7 @@ class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
+ ("nina", "0001_initial"),
(
"taggit",
"0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx",
@@ -132,8 +133,6 @@ class Migration(migrations.Migration):
),
),
("name", models.CharField(blank=True, max_length=255, null=True)),
- ("number", models.CharField(verbose_name="Genrequest number")),
- ("verified", models.BooleanField(default=False)),
("expected_total_samples", models.IntegerField(blank=True, null=True)),
(
"analysis_timerange",
@@ -165,21 +164,9 @@ class Migration(migrations.Migration):
),
),
(
- "members",
- models.ManyToManyField(
- blank=True,
- related_name="genrequests_member",
- to=settings.AUTH_USER_MODEL,
- ),
- ),
- (
- "owner",
+ "project",
models.ForeignKey(
- blank=True,
- null=True,
- on_delete=django.db.models.deletion.SET_NULL,
- related_name="genrequests_owned",
- to=settings.AUTH_USER_MODEL,
+ on_delete=django.db.models.deletion.PROTECT, to="nina.project"
),
),
],
diff --git a/src/genlab_bestilling/models.py b/src/genlab_bestilling/models.py
index 133ea55..9d188a0 100644
--- a/src/genlab_bestilling/models.py
+++ b/src/genlab_bestilling/models.py
@@ -90,18 +90,10 @@ class Genrequest(models.Model):
"""
name = models.CharField(max_length=255, null=True, blank=True)
- number = models.CharField(verbose_name=_("Project number"))
- verified = models.BooleanField(default=False)
+ project = models.ForeignKey("nina.Project", on_delete=models.PROTECT)
samples_owner = models.ForeignKey(
"Organization", on_delete=models.PROTECT, blank=True, null=True
)
- owner = models.ForeignKey(
- "users.User",
- on_delete=models.SET_NULL,
- null=True,
- blank=True,
- related_name="genrequests_owned",
- )
creator = models.ForeignKey(
"users.User",
on_delete=models.SET_NULL,
@@ -109,9 +101,6 @@ class Genrequest(models.Model):
blank=True,
related_name="genrequests_created",
)
- members = models.ManyToManyField(
- "users.User", blank=True, related_name="genrequests_member"
- )
area = models.ForeignKey("Area", on_delete=models.PROTECT)
species = models.ManyToManyField("Species", blank=True, related_name="genrequests")
sample_types = models.ManyToManyField("SampleType", blank=True)
@@ -120,7 +109,7 @@ class Genrequest(models.Model):
analysis_timerange = DateRangeField(null=True, blank=True)
def __str__(self):
- return str(self.name or self.number)
+ return str(self.name or self.project_id)
def get_absolute_url(self):
return reverse(
diff --git a/src/genlab_bestilling/tables.py b/src/genlab_bestilling/tables.py
index e0bd00c..09d7e53 100644
--- a/src/genlab_bestilling/tables.py
+++ b/src/genlab_bestilling/tables.py
@@ -18,12 +18,12 @@ def render_polymorphic_ctype(self, value):
class GenrequestTable(tables.Table):
- number = tables.Column(linkify=True)
+ project_id = tables.Column(linkify=True)
class Meta:
model = Genrequest
fields = (
- "number",
+ "project_id",
"name",
"area",
"species",
diff --git a/src/genlab_bestilling/templates/genlab_bestilling/genrequest_detail.html b/src/genlab_bestilling/templates/genlab_bestilling/genrequest_detail.html
index ea53853..342c8e3 100644
--- a/src/genlab_bestilling/templates/genlab_bestilling/genrequest_detail.html
+++ b/src/genlab_bestilling/templates/genlab_bestilling/genrequest_detail.html
@@ -4,20 +4,9 @@
{% block content %}
-
{{ object.name }} - {{ object.number }}
- {% if genrequest.verified %}Verified{% else %}Not verified{% endif %}
+ {{ object.project_id }} - {{ object.name|default_if_none:"Unnamed" }}
-{% if not genrequest.verified %}
-
-
Project number needs to be verified before adding orders
-
-{% endif %}
-
{% endblock %}
diff --git a/src/genlab_bestilling/templates/genlab_bestilling/order_list.html b/src/genlab_bestilling/templates/genlab_bestilling/order_list.html
index 1f78e8a..06b44f5 100644
--- a/src/genlab_bestilling/templates/genlab_bestilling/order_list.html
+++ b/src/genlab_bestilling/templates/genlab_bestilling/order_list.html
@@ -3,7 +3,7 @@
{% load render_table from django_tables2 %}
{% block content %}
- Orders relative to {{ genrequest.name }} - {{genrequest.number }}
+ Orders relative to {{ genrequest }}
back
Equipment order
diff --git a/src/genlab_bestilling/views.py b/src/genlab_bestilling/views.py
index ad3828a..2ed80cb 100644
--- a/src/genlab_bestilling/views.py
+++ b/src/genlab_bestilling/views.py
@@ -83,15 +83,24 @@ class GenrequestListView(LoginRequiredMixin, SingleTableView):
model = Genrequest
table_class = GenrequestTable
+ def get_queryset(self) -> QuerySet[Any]:
+ return super().get_queryset().filter(project__memberships=self.request.user)
+
class GenrequestDetailView(LoginRequiredMixin, DetailView):
model = Genrequest
+ def get_queryset(self) -> QuerySet[Any]:
+ return super().get_queryset().filter(project__memberships=self.request.user)
+
class GenrequestUpdateView(FormsetUpdateView):
model = Genrequest
form_class = GenrequestEditForm
+ def get_queryset(self) -> QuerySet[Any]:
+ return super().get_queryset().filter(project__memberships=self.request.user)
+
def get_success_url(self):
return reverse(
"genrequest-detail",
@@ -103,6 +112,9 @@ class GenrequestCreateView(FormsetCreateView):
model = Genrequest
form_class = GenrequestForm
+ def get_queryset(self) -> QuerySet[Any]:
+ return super().get_queryset().filter(project__memberships=self.request.user)
+
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["user"] = self.request.user
@@ -124,10 +136,18 @@ class GenrequestNestedMixin(LoginRequiredMixin):
and adds it to the render context.
"""
- genrequest_id_accessor = "genrequest_id"
+ genrequest_accessor = "genrequest"
def get_genrequest(self):
- return Genrequest.objects.get(id=self.kwargs["genrequest_id"], verified=True)
+ return Genrequest.objects.filter(project__memberships=self.request.user).get(
+ id=self.kwargs["genrequest_id"]
+ )
+
+ def get_project_filtered(self, qs):
+ filters = {
+ f"{self.genrequest_accessor}__project__memberships": self.request.user
+ }
+ return qs.filter(**filters)
def post(self, request, *args, **kwargs):
self.genrequest = self.get_genrequest()
@@ -138,8 +158,9 @@ def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
def get_queryset(self) -> QuerySet[Any]:
- kwargs = {self.genrequest_id_accessor: self.genrequest.id}
- return super().get_queryset().filter(**kwargs)
+ qs = self.get_project_filtered(qs=super().get_queryset())
+ kwargs = {f"{self.genrequest_accessor}_id": self.genrequest.id}
+ return qs.filter(**kwargs)
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
ctx = super().get_context_data(**kwargs)
@@ -256,7 +277,7 @@ class EquipmentOrderQuantityUpdateView(GenrequestNestedMixin, BulkEditCollection
collection_class = EquipmentQuantityCollection
template_name = "genlab_bestilling/equipmentorderquantity_form.html"
model = EquimentOrderQuantity
- genrequest_id_accessor = "order__genrequest_id"
+ genrequest_accessor = "order__genrequest"
def get_queryset(self) -> QuerySet[Any]:
return (
@@ -303,7 +324,7 @@ def get_queryset(self) -> QuerySet[Any]:
class SamplesListView(GenrequestNestedMixin, SingleTableView):
- genrequest_id_accessor = "order__genrequest_id"
+ genrequest_accessor = "order__genrequest"
model = Sample
table_class = SampleTable
@@ -316,7 +337,7 @@ class SamplesUpdateView(GenrequestNestedMixin, BulkEditCollectionView):
collection_class = SamplesCollection
template_name = "genlab_bestilling/sample_form.html"
model = Sample
- genrequest_id_accessor = "order__genrequest_id"
+ genrequest_accessor = "order__genrequest"
def get_queryset(self) -> QuerySet[Any]:
return (
diff --git a/src/nina/__init__.py b/src/nina/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/nina/admin.py b/src/nina/admin.py
new file mode 100644
index 0000000..3d76b8a
--- /dev/null
+++ b/src/nina/admin.py
@@ -0,0 +1,29 @@
+from django.contrib import admin
+from unfold.admin import ModelAdmin
+
+from .models import (
+ Project,
+ ProjectMembership,
+)
+
+
+class ProjectMembershipInline(admin.TabularInline):
+ model = ProjectMembership
+ autocomplete_fields = ["user"]
+
+
+@admin.register(Project)
+class ProjectAdmin(ModelAdmin):
+ search_fields = ["number", "name"]
+ list_filter = ["active"]
+ list_display = ["number", "name", "active"]
+
+ inlines = [ProjectMembershipInline]
+
+
+@admin.register(ProjectMembership)
+class ProjectMembershipAdmin(ModelAdmin):
+ search_fields = ["project__number", "project__name", "user__email"]
+ list_filter = ["role"]
+ list_display = ["project", "user", "role"]
+ autocomplete_fields = ["project", "user"]
diff --git a/src/nina/apps.py b/src/nina/apps.py
new file mode 100644
index 0000000..66b55b5
--- /dev/null
+++ b/src/nina/apps.py
@@ -0,0 +1,7 @@
+from django.apps import AppConfig as DjangoAppConfig
+from django.utils.translation import gettext_lazy as _
+
+
+class AppConfig(DjangoAppConfig):
+ name = "nina"
+ verbose_name = _("NINA")
diff --git a/src/nina/migrations/0001_initial.py b/src/nina/migrations/0001_initial.py
new file mode 100644
index 0000000..a0b3bc8
--- /dev/null
+++ b/src/nina/migrations/0001_initial.py
@@ -0,0 +1,78 @@
+# Generated by Django 5.0.7 on 2024-07-25 07:58
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Project",
+ fields=[
+ ("number", models.CharField(primary_key=True, serialize=False)),
+ ("name", models.CharField(blank=True, null=True)),
+ ("active", models.BooleanField(default=True)),
+ ],
+ ),
+ migrations.CreateModel(
+ name="ProjectMembership",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "role",
+ models.CharField(
+ choices=[
+ ("owner", "Owner"),
+ ("manager", "Manager"),
+ ("member", "Member"),
+ ],
+ default="member",
+ ),
+ ),
+ (
+ "project",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE, to="nina.project"
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="memberships",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ ),
+ migrations.AddField(
+ model_name="project",
+ name="memberships",
+ field=models.ManyToManyField(
+ blank=True,
+ through="nina.ProjectMembership",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="projectmembership",
+ constraint=models.UniqueConstraint(
+ fields=("project", "user"), name="unique_user_per_project"
+ ),
+ ),
+ ]
diff --git a/src/nina/migrations/__init__.py b/src/nina/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/nina/models.py b/src/nina/models.py
new file mode 100644
index 0000000..1aa41e1
--- /dev/null
+++ b/src/nina/models.py
@@ -0,0 +1,42 @@
+from django.db import models
+
+
+class ProjectMembership(models.Model):
+ class Role(models.TextChoices):
+ OWNER = "owner", "Owner"
+ MANAGER = "manager", "Manager"
+ MEMBER = "member", "Member"
+
+ project = models.ForeignKey("Project", on_delete=models.CASCADE)
+ user = models.ForeignKey(
+ "users.User", on_delete=models.CASCADE, related_name="memberships"
+ )
+ role = models.CharField(
+ choices=Role,
+ default=Role.MEMBER,
+ )
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(
+ fields=["project", "user"], name="unique_user_per_project"
+ )
+ ]
+
+ def __str__(self) -> str:
+ return f"{self.project_id} {self.user} - {self.get_role_display()}"
+
+
+class Project(models.Model):
+ number = models.CharField(primary_key=True)
+ name = models.CharField(null=True, blank=True)
+ memberships = models.ManyToManyField(
+ "users.User", through=ProjectMembership, blank=True
+ )
+ active = models.BooleanField(default=True)
+
+ def __str__(self) -> str:
+ if self.name:
+ return "{self.number} {self.name}"
+
+ return self.number
diff --git a/src/nina/urls.py b/src/nina/urls.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/nina/views.py b/src/nina/views.py
new file mode 100644
index 0000000..e69de29