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 %} -
{% object-detail object=object %} @@ -28,7 +17,6 @@

{{ object.name }} - {{ object.number }}

Edit - {% if genrequest.verified %} {{ object.name }} - {{ object.number }} href="{% url 'genrequest-analysis-create' genrequest_id=genrequest.id %}" > Analysis order - {% 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