From dd08c319f1641a07c9bd4bfc850b6ae2fd02a1f6 Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Mon, 31 Mar 2025 17:39:17 +0200
Subject: [PATCH 1/6] Fix migration history

---
 .../migrations/0018_item_contained_in.py      | 19 -------------------
 ...0018_location_item_alter_category_icon.py} |  2 +-
 2 files changed, 1 insertion(+), 20 deletions(-)
 delete mode 100644 aleksis/apps/plank/migrations/0018_item_contained_in.py
 rename aleksis/apps/plank/migrations/{0019_location_item_alter_category_icon.py => 0018_location_item_alter_category_icon.py} (86%)

diff --git a/aleksis/apps/plank/migrations/0018_item_contained_in.py b/aleksis/apps/plank/migrations/0018_item_contained_in.py
deleted file mode 100644
index 1e8c89b..0000000
--- a/aleksis/apps/plank/migrations/0018_item_contained_in.py
+++ /dev/null
@@ -1,19 +0,0 @@
-# Generated by Django 4.2.11 on 2024-05-01 09:50
-
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('plank', '0017_category_manufacturer_item_type_remove_inventory'),
-    ]
-
-    operations = [
-        migrations.AddField(
-            model_name='item',
-            name='contained_in',
-            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contains', to='plank.item', verbose_name='Contained in'),
-        ),
-    ]
diff --git a/aleksis/apps/plank/migrations/0019_location_item_alter_category_icon.py b/aleksis/apps/plank/migrations/0018_location_item_alter_category_icon.py
similarity index 86%
rename from aleksis/apps/plank/migrations/0019_location_item_alter_category_icon.py
rename to aleksis/apps/plank/migrations/0018_location_item_alter_category_icon.py
index 0ff33c4..06a24e7 100644
--- a/aleksis/apps/plank/migrations/0019_location_item_alter_category_icon.py
+++ b/aleksis/apps/plank/migrations/0018_location_item_alter_category_icon.py
@@ -7,7 +7,7 @@ import django.db.models.deletion
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('plank', '0018_item_contained_in'),
+        ('plank', '0017_category_manufacturer_item_type_remove_inventory'),
     ]
 
     operations = [
-- 
GitLab


From a8328d7ad22bc6470a988fb38d6afde944d0b3ac Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Mon, 31 Mar 2025 17:40:38 +0200
Subject: [PATCH 2/6] Add anonymous option to item and show it in frontend

---
 aleksis/apps/plank/forms.py                   |  2 +
 .../components/attributeRows/CountRow.vue     | 32 +++++++++++++++
 .../frontend/components/inputs/CountInput.vue | 40 +++++++++++++++++++
 .../components/inputs/IsAnonymousInput.vue    | 32 +++++++++++++++
 .../components/item/AnonymousChip.vue         | 13 ++++++
 .../frontend/components/item/ItemBaseList.vue |  9 +++++
 .../frontend/components/item/ItemDetail.vue   |  3 ++
 .../frontend/components/item/ItemStatus.vue   |  1 +
 .../components/item/fragments.graphql         |  2 +
 aleksis/apps/plank/frontend/defaultIcons.js   |  1 +
 aleksis/apps/plank/frontend/defaultItems.js   |  2 +
 aleksis/apps/plank/frontend/messages/en.json  |  6 ++-
 aleksis/apps/plank/frontend/rules.js          |  3 ++
 .../plank/migrations/0019_anonymous_item.py   | 23 +++++++++++
 aleksis/apps/plank/models.py                  |  8 ++++
 aleksis/apps/plank/schema.py                  |  2 +
 16 files changed, 178 insertions(+), 1 deletion(-)
 create mode 100644 aleksis/apps/plank/frontend/components/attributeRows/CountRow.vue
 create mode 100644 aleksis/apps/plank/frontend/components/inputs/CountInput.vue
 create mode 100644 aleksis/apps/plank/frontend/components/inputs/IsAnonymousInput.vue
 create mode 100644 aleksis/apps/plank/frontend/components/item/AnonymousChip.vue
 create mode 100644 aleksis/apps/plank/migrations/0019_anonymous_item.py

diff --git a/aleksis/apps/plank/forms.py b/aleksis/apps/plank/forms.py
index b2fc283..d78b69f 100644
--- a/aleksis/apps/plank/forms.py
+++ b/aleksis/apps/plank/forms.py
@@ -45,6 +45,8 @@ class ItemForm(ModelForm):
             "location",
             "serial_number",
             "inventory",
+            "is_anonymous",
+            "count",
         )
 
 
diff --git a/aleksis/apps/plank/frontend/components/attributeRows/CountRow.vue b/aleksis/apps/plank/frontend/components/attributeRows/CountRow.vue
new file mode 100644
index 0000000..63706eb
--- /dev/null
+++ b/aleksis/apps/plank/frontend/components/attributeRows/CountRow.vue
@@ -0,0 +1,32 @@
+<template>
+  <attribute-row
+    :icon="defaultIcons.count"
+    :label="$t('plank.labels.available_count')"
+  >
+    {{ data.count }}
+    <anonymous-chip />
+  </attribute-row>
+</template>
+
+<script>
+import AttributeRow from "./AttributeRow.vue";
+import defaultIcons from "../../defaultIcons";
+import AnonymousChip from "../item/AnonymousChip.vue";
+
+export default {
+  name: "CountRow",
+  components: { AnonymousChip, AttributeRow },
+  props: {
+    data: {
+      type: Object,
+      required: false,
+      default: null,
+    },
+  },
+  data() {
+    return {
+      defaultIcons: defaultIcons,
+    };
+  },
+};
+</script>
diff --git a/aleksis/apps/plank/frontend/components/inputs/CountInput.vue b/aleksis/apps/plank/frontend/components/inputs/CountInput.vue
new file mode 100644
index 0000000..cd53869
--- /dev/null
+++ b/aleksis/apps/plank/frontend/components/inputs/CountInput.vue
@@ -0,0 +1,40 @@
+<template>
+  <v-text-field
+    filled
+    v-model.number="innerValue"
+    type="number"
+    :label="$t('plank.labels.count')"
+    :rules="rules.count"
+    :prepend-icon="defaultIcons.count"
+  ></v-text-field>
+</template>
+
+<script>
+import rules from "../../rules";
+import defaultIcons from "../../defaultIcons";
+
+export default {
+  name: "CountInput",
+  props: {
+    value: {
+      type: Number | String,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      rules: rules,
+      defaultIcons: defaultIcons,
+      innerValue: this.value,
+    };
+  },
+  watch: {
+    value(val) {
+      this.innerValue = val;
+    },
+    innerValue(val) {
+      this.$emit("input", val);
+    },
+  },
+};
+</script>
diff --git a/aleksis/apps/plank/frontend/components/inputs/IsAnonymousInput.vue b/aleksis/apps/plank/frontend/components/inputs/IsAnonymousInput.vue
new file mode 100644
index 0000000..196dd9d
--- /dev/null
+++ b/aleksis/apps/plank/frontend/components/inputs/IsAnonymousInput.vue
@@ -0,0 +1,32 @@
+<template>
+  <v-switch
+    inset
+    v-model="innerValue"
+    :label="$t('plank.labels.is_anonymous')"
+  ></v-switch>
+</template>
+
+<script>
+export default {
+  name: "IsAnonymousInput",
+  props: {
+    value: {
+      type: Boolean,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      innerValue: this.value,
+    };
+  },
+  watch: {
+    value(val) {
+      this.innerValue = val;
+    },
+    innerValue(val) {
+      this.$emit("input", val);
+    },
+  },
+};
+</script>
diff --git a/aleksis/apps/plank/frontend/components/item/AnonymousChip.vue b/aleksis/apps/plank/frontend/components/item/AnonymousChip.vue
new file mode 100644
index 0000000..1df0b17
--- /dev/null
+++ b/aleksis/apps/plank/frontend/components/item/AnonymousChip.vue
@@ -0,0 +1,13 @@
+<script>
+export default {
+  name: "AnonymousChip",
+};
+</script>
+
+<template>
+  <v-chip label small outlined color="purple" v-bind="$attrs">
+    {{ $t("plank.item.anonymous") }}
+  </v-chip>
+</template>
+
+<style scoped></style>
diff --git a/aleksis/apps/plank/frontend/components/item/ItemBaseList.vue b/aleksis/apps/plank/frontend/components/item/ItemBaseList.vue
index c784300..528e523 100644
--- a/aleksis/apps/plank/frontend/components/item/ItemBaseList.vue
+++ b/aleksis/apps/plank/frontend/components/item/ItemBaseList.vue
@@ -46,6 +46,7 @@
         :to="{ name: 'plank.location', params: { id: item.isLocation.id } }"
         class="ml-2"
       />
+      <anonymous-chip v-if="item.isAnonymous" class="ml-2" />
     </template>
     <!-- eslint-disable-next-line vue/valid-v-slot -->
     <template #item.status="{ item }">
@@ -61,6 +62,8 @@
         v-model="currentItem.itemType"
         :item-types="formData.itemTypes"
       />
+      <is-anonymous-input v-model="currentItem.isAnonymous" />
+      <count-input v-if="currentItem.isAnonymous" v-model="currentItem.count" />
       <name-input v-model="currentItem.name" />
       <location-input
         v-model="currentItem.location"
@@ -88,12 +91,16 @@ import SerialNumberInput from "../inputs/SerialNumberInput.vue";
 import ItemTypeInput from "../inputs/ItemTypeInput.vue";
 import ScanInput from "../inputs/ScanInput.vue";
 import ContainerChip from "./ContainerChip.vue";
+import AnonymousChip from "./AnonymousChip.vue";
+import IsAnonymousInput from "../inputs/IsAnonymousInput.vue";
+import CountInput from "../inputs/CountInput.vue";
 
 import { items, deleteItem, updateItem, itemFormData } from "./queries.graphql";
 
 export default {
   name: "ItemBaseList",
   components: {
+    IsAnonymousInput,
     ContainerChip,
     SerialNumberInput,
     LocationInput,
@@ -107,6 +114,8 @@ export default {
     ItemId,
     ItemTypeInput,
     ScanInput,
+    AnonymousChip,
+    CountInput,
   },
   apollo: {
     formData: {
diff --git a/aleksis/apps/plank/frontend/components/item/ItemDetail.vue b/aleksis/apps/plank/frontend/components/item/ItemDetail.vue
index 80c42bd..f2a15b3 100644
--- a/aleksis/apps/plank/frontend/components/item/ItemDetail.vue
+++ b/aleksis/apps/plank/frontend/components/item/ItemDetail.vue
@@ -7,6 +7,7 @@
           <attributes-table>
             <barcode-row :data="item.barcode" />
             <inventory-row :data="item.inventory" />
+            <count-row v-if="item.isAnonymous" :data="item" />
             <is-location-row :data="item.isLocation" />
             <category-row :data="item.category" />
             <item-type-row :data="item.itemType" />
@@ -40,6 +41,7 @@ import LastTimeSeenAtRow from "../attributeRows/LastTimeSeenAtRow.vue";
 import ItemStatusRow from "../attributeRows/ItemStatusRow.vue";
 import CheckOutHistoryTable from "./CheckOutHistoryTable.vue";
 import IsLocationRow from "../attributeRows/IsLocationRow.vue";
+import CountRow from "../attributeRows/CountRow.vue";
 
 import { item } from "./queries.graphql";
 
@@ -59,6 +61,7 @@ export default {
     DetailPage,
     AttributesTable,
     ItemStatusRow,
+    CountRow,
   },
   data() {
     return {
diff --git a/aleksis/apps/plank/frontend/components/item/ItemStatus.vue b/aleksis/apps/plank/frontend/components/item/ItemStatus.vue
index a8802ae..c8acc3a 100644
--- a/aleksis/apps/plank/frontend/components/item/ItemStatus.vue
+++ b/aleksis/apps/plank/frontend/components/item/ItemStatus.vue
@@ -2,6 +2,7 @@
   <div v-if="item.isAvailable">
     <v-chip color="green" text-color="white" label small>
       {{ $t("plank.item.status.available") }}
+      <template v-if="item.isAnonymous">({{ item.count }})</template>
     </v-chip>
 
     <span v-if="item.location"> ({{ item.location.name }}) </span>
diff --git a/aleksis/apps/plank/frontend/components/item/fragments.graphql b/aleksis/apps/plank/frontend/components/item/fragments.graphql
index 7422f71..86ab947 100644
--- a/aleksis/apps/plank/frontend/components/item/fragments.graphql
+++ b/aleksis/apps/plank/frontend/components/item/fragments.graphql
@@ -30,6 +30,8 @@ fragment ItemExtendedParts on ItemGrapheneType {
   notes
   lastTimeSeenAt
   isAvailable
+  isAnonymous
+  count
   currentCheckedOutItem {
     id
     process {
diff --git a/aleksis/apps/plank/frontend/defaultIcons.js b/aleksis/apps/plank/frontend/defaultIcons.js
index aff29b5..4e9eb9f 100644
--- a/aleksis/apps/plank/frontend/defaultIcons.js
+++ b/aleksis/apps/plank/frontend/defaultIcons.js
@@ -23,4 +23,5 @@ export default {
   date: "mdi-calendar-outline",
   checkedOutAt: "mdi-clock-outline",
   containedIn: "mdi-archive-outline",
+  count: "mdi-counter",
 };
diff --git a/aleksis/apps/plank/frontend/defaultItems.js b/aleksis/apps/plank/frontend/defaultItems.js
index 95c4dda..9d3af86 100644
--- a/aleksis/apps/plank/frontend/defaultItems.js
+++ b/aleksis/apps/plank/frontend/defaultItems.js
@@ -25,6 +25,8 @@ export default {
     itemType: null,
     location: null,
     serialNumber: "",
+    isAnonymous: false,
+    count: 1,
   },
   checkOutProcess: {
     checkInUntil: null,
diff --git a/aleksis/apps/plank/frontend/messages/en.json b/aleksis/apps/plank/frontend/messages/en.json
index b586c55..b98f1d8 100644
--- a/aleksis/apps/plank/frontend/messages/en.json
+++ b/aleksis/apps/plank/frontend/messages/en.json
@@ -100,6 +100,7 @@
       "menu_title": "Items",
       "delete_question": "Are you sure that you want to delete this item?",
       "container": "Container",
+      "anonymous": "Anonymous",
       "actions": {
         "create": "Create new item",
         "edit": "Edit item",
@@ -210,7 +211,10 @@
       "borrowing_person": "Checked out to",
       "lending_person": "Checked out by",
       "check_in_until": "Check in until",
-      "is_location": "Is location"
+      "is_location": "Is location",
+      "is_anonymous": "Is anonymous",
+      "count": "Count",
+      "available_count": "Available count"
     },
     "loading": "Loading ...",
     "actions": {
diff --git a/aleksis/apps/plank/frontend/rules.js b/aleksis/apps/plank/frontend/rules.js
index 334cfb7..a28bb77 100644
--- a/aleksis/apps/plank/frontend/rules.js
+++ b/aleksis/apps/plank/frontend/rules.js
@@ -16,4 +16,7 @@ export default {
     (v) => !v || new Date(v) >= new Date() || "Date has to be after today",
   ],
   item: [],
+  count: [
+    (v) => Number.parseInt(v) > 0 || "Count has to larger or equal than zero",
+  ],
 };
diff --git a/aleksis/apps/plank/migrations/0019_anonymous_item.py b/aleksis/apps/plank/migrations/0019_anonymous_item.py
new file mode 100644
index 0000000..a31d1da
--- /dev/null
+++ b/aleksis/apps/plank/migrations/0019_anonymous_item.py
@@ -0,0 +1,23 @@
+# Generated by Django 5.1.7 on 2025-03-31 11:11
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('plank', '0018_location_item_alter_category_icon'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='item',
+            name='count',
+            field=models.PositiveSmallIntegerField(default=1, verbose_name='Item count'),
+        ),
+        migrations.AddField(
+            model_name='item',
+            name='is_anonymous',
+            field=models.BooleanField(default=False, verbose_name='Is anonymous?'),
+        ),
+    ]
diff --git a/aleksis/apps/plank/models.py b/aleksis/apps/plank/models.py
index 1331cb6..f1fb4b8 100644
--- a/aleksis/apps/plank/models.py
+++ b/aleksis/apps/plank/models.py
@@ -267,6 +267,9 @@ class Item(ExtensibleModel):
     )
     last_time_seen_at = models.DateTimeField(auto_now=True, verbose_name=_("Last time seen at"))
 
+    is_anonymous = models.BooleanField(default=False, verbose_name=_("Is anonymous?"))
+    count = models.PositiveSmallIntegerField(default=1, verbose_name=_("Item count"))
+
     class Meta:
         ordering = ["name", "item_type__name"]
         verbose_name = _("Item")
@@ -282,6 +285,9 @@ class Item(ExtensibleModel):
         if not self.barcode:
             self.barcode = self.gen_barcode()
 
+        if not self.is_anonymous:
+            self.count = 1
+
         super().save(*args, **kwargs)
 
     def gen_barcode(self) -> str:
@@ -298,6 +304,8 @@ class Item(ExtensibleModel):
     @property
     def is_available(self):
         """Check if item is available."""
+        if self.is_anonymous:
+            return self.count > 0
         return not self.current_checked_out_item
 
     @property
diff --git a/aleksis/apps/plank/schema.py b/aleksis/apps/plank/schema.py
index 4d4040e..fb97e06 100644
--- a/aleksis/apps/plank/schema.py
+++ b/aleksis/apps/plank/schema.py
@@ -269,6 +269,8 @@ class ItemGrapheneType(PermissionsForTypeMixin, DjangoObjectType):
             "inventory",
             "is_location",
             "id",
+            "is_anonymous",
+            "count",
         )
 
 
-- 
GitLab


From 8f018713d92715483b483d2493923269c4c85869 Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Mon, 31 Mar 2025 17:40:57 +0200
Subject: [PATCH 3/6] Fix displaying of datetimes

---
 .../components/attributeRows/CheckInUntilRow.vue   |  2 +-
 .../components/attributeRows/CheckedOutAtRow.vue   |  2 +-
 .../components/attributeRows/LastTimeSeenAtRow.vue |  2 +-
 .../check_out_process/CheckOutProcessBaseList.vue  |  2 +-
 .../components/item/CheckOutHistoryTable.vue       | 14 +++++++++++++-
 5 files changed, 17 insertions(+), 5 deletions(-)

diff --git a/aleksis/apps/plank/frontend/components/attributeRows/CheckInUntilRow.vue b/aleksis/apps/plank/frontend/components/attributeRows/CheckInUntilRow.vue
index 85b920a..801a6ea 100644
--- a/aleksis/apps/plank/frontend/components/attributeRows/CheckInUntilRow.vue
+++ b/aleksis/apps/plank/frontend/components/attributeRows/CheckInUntilRow.vue
@@ -3,7 +3,7 @@
     :icon="defaultIcons.date"
     :label="$t('plank.labels.check_in_until')"
   >
-    {{ data ? data : "–" }}
+    {{ data ? $d($parseISODate(data), "short") : "–" }}
   </attribute-row>
 </template>
 
diff --git a/aleksis/apps/plank/frontend/components/attributeRows/CheckedOutAtRow.vue b/aleksis/apps/plank/frontend/components/attributeRows/CheckedOutAtRow.vue
index c2cbf25..f13acf0 100644
--- a/aleksis/apps/plank/frontend/components/attributeRows/CheckedOutAtRow.vue
+++ b/aleksis/apps/plank/frontend/components/attributeRows/CheckedOutAtRow.vue
@@ -3,7 +3,7 @@
     :icon="defaultIcons.checkedOutAt"
     :label="$t('plank.labels.checked_out_at')"
   >
-    {{ data ? data : "–" }}
+    {{ data ? $d($parseISODate(data), "shortDateTime") : "–" }}
   </attribute-row>
 </template>
 
diff --git a/aleksis/apps/plank/frontend/components/attributeRows/LastTimeSeenAtRow.vue b/aleksis/apps/plank/frontend/components/attributeRows/LastTimeSeenAtRow.vue
index 725b2f3..2753479 100644
--- a/aleksis/apps/plank/frontend/components/attributeRows/LastTimeSeenAtRow.vue
+++ b/aleksis/apps/plank/frontend/components/attributeRows/LastTimeSeenAtRow.vue
@@ -3,7 +3,7 @@
     :icon="defaultIcons.lastTimeSeenAt"
     :label="$t('plank.labels.last_time_seen_at')"
   >
-    {{ data ? data : "–" }}
+    {{ data ? $d($parseISODate(data), "shortDateTime") : "–" }}
   </attribute-row>
 </template>
 
diff --git a/aleksis/apps/plank/frontend/components/check_out_process/CheckOutProcessBaseList.vue b/aleksis/apps/plank/frontend/components/check_out_process/CheckOutProcessBaseList.vue
index 5d5bb3f..39cc3cf 100644
--- a/aleksis/apps/plank/frontend/components/check_out_process/CheckOutProcessBaseList.vue
+++ b/aleksis/apps/plank/frontend/components/check_out_process/CheckOutProcessBaseList.vue
@@ -21,7 +21,7 @@
       <router-link
         :to="{ name: 'plank.checkOutProcess', params: { id: item.id } }"
       >
-        {{ item.checkedOutAt }}
+        {{ $d($parseISODate(item.checkedOutAt), "shortDateTime") }}
       </router-link>
     </template>
     <!-- eslint-disable-next-line vue/valid-v-slot -->
diff --git a/aleksis/apps/plank/frontend/components/item/CheckOutHistoryTable.vue b/aleksis/apps/plank/frontend/components/item/CheckOutHistoryTable.vue
index 795f7b3..4a7802c 100644
--- a/aleksis/apps/plank/frontend/components/item/CheckOutHistoryTable.vue
+++ b/aleksis/apps/plank/frontend/components/item/CheckOutHistoryTable.vue
@@ -17,10 +17,22 @@
     </template>
     <!-- eslint-disable-next-line vue/valid-v-slot -->
     <template #item.actions="{ item }">
-      <v-btn text color="primary">
+      <v-btn
+        text
+        color="primary"
+        :to="{ name: 'plank.checkOutProcess', params: { id: item.id } }"
+      >
         {{ $t("plank.actions.show_more") }}
       </v-btn>
     </template>
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #item.process.checkedOutAt="{ item }">
+      {{ $d($parseISODate(item.process.checkedOutAt), "shortDateTime") }}
+    </template>
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #item.checkedInAt="{ item }">
+      {{ $d($parseISODate(item.checkedInAt), "shortDateTime") }}
+    </template>
   </v-data-table>
 </template>
 
-- 
GitLab


From bf89e2e3096d56664aa37eb647c15941088fadf9 Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Mon, 31 Mar 2025 17:41:09 +0200
Subject: [PATCH 4/6] Use Core 4.0.0

---
 pyproject.toml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/pyproject.toml b/pyproject.toml
index e799d7e..54b3b9d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -34,7 +34,7 @@ url = "https://edugit.org/api/v4/projects/461/packages/pypi/simple"
 priority = "supplemental"
 [tool.poetry.dependencies]
 python = "^3.10"
-aleksis-core = "^4.0.0.dev11"
+aleksis-core = "^4.0.0"
 
 [tool.poetry.plugins."aleksis.app"]
 plank = "aleksis.apps.plank.apps:PlankConfig"
-- 
GitLab


From a608d478090dd267af9aa797fa17be0a6a40343c Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Tue, 1 Apr 2025 13:46:25 +0200
Subject: [PATCH 5/6] Implement check out of anonymous items

---
 .../components/check_out/CheckOutPage.vue     | 58 ++++++++++++--
 .../CheckOutProcessDetail.vue                 | 26 ++++++-
 .../check_out_process/queries.graphql         |  1 +
 .../frontend/components/inputs/CountInput.vue |  2 +-
 .../components/item/CheckOutHistoryTable.vue  | 13 +++-
 .../frontend/components/item/queries.graphql  |  1 +
 aleksis/apps/plank/frontend/messages/en.json  |  1 +
 .../migrations/0020_checkedoutitem_count.py   | 18 +++++
 aleksis/apps/plank/models.py                  |  6 ++
 aleksis/apps/plank/schema.py                  | 76 +++++++++++--------
 .../templates/plank/reports/check-out.html    |  2 +-
 11 files changed, 159 insertions(+), 45 deletions(-)
 create mode 100644 aleksis/apps/plank/migrations/0020_checkedoutitem_count.py

diff --git a/aleksis/apps/plank/frontend/components/check_out/CheckOutPage.vue b/aleksis/apps/plank/frontend/components/check_out/CheckOutPage.vue
index e702995..335cd91 100644
--- a/aleksis/apps/plank/frontend/components/check_out/CheckOutPage.vue
+++ b/aleksis/apps/plank/frontend/components/check_out/CheckOutPage.vue
@@ -41,21 +41,36 @@
             <v-list>
               <template v-for="(item, index) in items">
                 <v-list-item :key="item.id">
+                  <v-list-item-icon class="mr-2">
+                    <v-text-field
+                      class="count-input"
+                      v-model.number="item.checkOutCount"
+                      outlined
+                      dense
+                      hide-details
+                    ></v-text-field>
+                  </v-list-item-icon>
                   <v-list-item-content>
                     <v-list-item-title>
                       <item-label :item="item" :with-barcode="false" />
+                    </v-list-item-title>
+                    <v-list-item-subtitle>
+                      <item-id :item="item" />
                       <v-chip
                         v-if="item?.byContainer"
                         label
                         small
                         outlined
                         color="purple"
+                        class="mx-1"
                       >
                         {{ item.location.name }}
                       </v-chip>
-                    </v-list-item-title>
-                    <v-list-item-subtitle>
-                      <item-id :item="item" />
+                      <anonymous-chip
+                        small
+                        v-if="item.isAnonymous"
+                        class="mx-1"
+                      />
                     </v-list-item-subtitle>
                   </v-list-item-content>
                   <v-list-item-action>
@@ -96,7 +111,7 @@
               input: {
                 inventory,
                 borrowingPerson: person,
-                items: itemIds,
+                items: mutationItems,
                 checkOutCondition,
                 checkInUntil: checkInUntil || null,
               },
@@ -153,6 +168,9 @@
       <span v-if="snackbarStatus === 'already_added'">
         {{ $t("plank.check_out.process.messages.already_added") }}
       </span>
+      <span v-else-if="snackbarStatus === 'no_more_available'">
+        {{ $t("plank.check_out.process.messages.no_more_available") }}
+      </span>
       <span v-else-if="snackbarStatus === 'not_found'">
         {{ $t("plank.check_out.process.messages.not_found") }}
       </span>
@@ -182,9 +200,11 @@ import {
   checkOutInventories,
   checkOut,
 } from "./queries.graphql";
+import AnonymousChip from "../item/AnonymousChip.vue";
 export default {
   name: "CheckOutPage",
   components: {
+    AnonymousChip,
     CheckInUntilInput,
     CheckOutConditionInput,
     InventoryInput,
@@ -232,6 +252,11 @@ export default {
     itemIds() {
       return this.items.map((item) => item.id);
     },
+    mutationItems() {
+      return this.items.map((item) => {
+        return { id: item.id, count: item.checkOutCount };
+      });
+    },
     fullInventory() {
       if (!this.inventories) {
         return;
@@ -271,11 +296,23 @@ export default {
           if (!data.item.item) {
             this.snackbarStatus = "not_found";
           } else if (this.itemIds.includes(data.item.item.id)) {
-            this.snackbarStatus = "already_added";
+            if (data.item.item.isAnonymous) {
+              let existingItem = this.items.find(
+                (i) => i.id === data.item.item.id,
+              );
+              if (existingItem.checkOutCount < data.item.item.count) {
+                existingItem.checkOutCount++;
+                existingItem.count = data.item.item.count;
+              } else {
+                this.snackbarStatus = "no_more_available";
+              }
+            } else {
+              this.snackbarStatus = "already_added";
+            }
           } else if (!data.item.item.isAvailable) {
             this.snackbarStatus = "checked_out";
           } else {
-            this.items.push(data.item.item);
+            this.items.push({ ...data.item.item, checkOutCount: 1 });
             this.snackbarStatus = "added";
           }
 
@@ -325,3 +362,12 @@ export default {
   },
 };
 </script>
+
+<style scoped>
+.count-input {
+  width: 40px;
+}
+.count-input input {
+  text-align: center;
+}
+</style>
diff --git a/aleksis/apps/plank/frontend/components/check_out_process/CheckOutProcessDetail.vue b/aleksis/apps/plank/frontend/components/check_out_process/CheckOutProcessDetail.vue
index fe05463..8ac5298 100644
--- a/aleksis/apps/plank/frontend/components/check_out_process/CheckOutProcessDetail.vue
+++ b/aleksis/apps/plank/frontend/components/check_out_process/CheckOutProcessDetail.vue
@@ -2,11 +2,21 @@
   <detail-page :id="$route.params.id" :gql-query="gqlCheckOutProcess">
     <template #title="{ item }"></template>
     <template #additional-actions="{ item }">
-      <v-btn color="primary" :href="item.checkOutForm" target="_blank">
+      <v-btn
+        color="primary"
+        :href="item.checkOutForm"
+        target="_blank"
+        class="ml-1"
+      >
         <v-icon left>mdi-file-pdf-box</v-icon>
         {{ $t("plank.check_out.process.download_form") }}
       </v-btn>
-      <v-btn color="primary" :href="item.checkInForm" target="_blank">
+      <v-btn
+        color="primary"
+        :href="item.checkInForm"
+        target="_blank"
+        class="ml-1"
+      >
         <v-icon left>mdi-file-pdf-box</v-icon>
         {{ $t("plank.check_in.process.download_form") }}
       </v-btn>
@@ -34,9 +44,19 @@
                 <template
                   v-for="(checkedOutItem, index) in item.checkedOutItems"
                 >
-                  <v-list-item :key="checkedOutItem.id">
+                  <v-list-item
+                    :key="checkedOutItem.id"
+                    :to="{
+                      name: 'plank.item',
+                      params: { id: checkedOutItem.item.id },
+                    }"
+                  >
                     <v-list-item-content>
                       <v-list-item-title>
+                        <template v-if="checkedOutItem.count !== 1">
+                          <!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
+                          {{ checkedOutItem.count }} &times;
+                        </template>
                         <item-label
                           :item="checkedOutItem.item"
                           :with-barcode="false"
diff --git a/aleksis/apps/plank/frontend/components/check_out_process/queries.graphql b/aleksis/apps/plank/frontend/components/check_out_process/queries.graphql
index 0d952fc..c6cb71d 100644
--- a/aleksis/apps/plank/frontend/components/check_out_process/queries.graphql
+++ b/aleksis/apps/plank/frontend/components/check_out_process/queries.graphql
@@ -15,6 +15,7 @@ query checkOutProcess($id: ID!) {
     ...CheckOutProcessExtendedParts
     checkedOutItems {
       id
+      count
       checkedIn
       item {
         ...ItemBaseParts
diff --git a/aleksis/apps/plank/frontend/components/inputs/CountInput.vue b/aleksis/apps/plank/frontend/components/inputs/CountInput.vue
index cd53869..6e82c79 100644
--- a/aleksis/apps/plank/frontend/components/inputs/CountInput.vue
+++ b/aleksis/apps/plank/frontend/components/inputs/CountInput.vue
@@ -17,7 +17,7 @@ export default {
   name: "CountInput",
   props: {
     value: {
-      type: Number | String,
+      type: Number,
       required: true,
     },
   },
diff --git a/aleksis/apps/plank/frontend/components/item/CheckOutHistoryTable.vue b/aleksis/apps/plank/frontend/components/item/CheckOutHistoryTable.vue
index 4a7802c..14a040d 100644
--- a/aleksis/apps/plank/frontend/components/item/CheckOutHistoryTable.vue
+++ b/aleksis/apps/plank/frontend/components/item/CheckOutHistoryTable.vue
@@ -20,7 +20,7 @@
       <v-btn
         text
         color="primary"
-        :to="{ name: 'plank.checkOutProcess', params: { id: item.id } }"
+        :to="{ name: 'plank.checkOutProcess', params: { id: item.process.id } }"
       >
         {{ $t("plank.actions.show_more") }}
       </v-btn>
@@ -31,7 +31,11 @@
     </template>
     <!-- eslint-disable-next-line vue/valid-v-slot -->
     <template #item.checkedInAt="{ item }">
-      {{ $d($parseISODate(item.checkedInAt), "shortDateTime") }}
+      {{
+        item.checkedInAt
+          ? $d($parseISODate(item.checkedInAt), "shortDateTime")
+          : item.checkedInAt
+      }}
     </template>
   </v-data-table>
 </template>
@@ -57,6 +61,10 @@ export default {
           value: "process.checkedOutAt",
           text: this.$t("plank.labels.checked_out_at"),
         },
+        {
+          value: "count",
+          text: this.$t("plank.labels.count"),
+        },
         {
           value: "checkedInAt",
           text: this.$t("plank.labels.checked_in_at"),
@@ -70,7 +78,6 @@ export default {
           text: this.$t("plank.actions.title"),
           sortable: false,
         },
-        // FIXME ACtions column with link
       ];
     },
   },
diff --git a/aleksis/apps/plank/frontend/components/item/queries.graphql b/aleksis/apps/plank/frontend/components/item/queries.graphql
index 5065114..0bae38c 100644
--- a/aleksis/apps/plank/frontend/components/item/queries.graphql
+++ b/aleksis/apps/plank/frontend/components/item/queries.graphql
@@ -22,6 +22,7 @@ query item($id: ID!) {
     checkedOutItems {
       id
       checkedInAt
+      count
       process {
         id
         checkedOutAt
diff --git a/aleksis/apps/plank/frontend/messages/en.json b/aleksis/apps/plank/frontend/messages/en.json
index b98f1d8..33c3e53 100644
--- a/aleksis/apps/plank/frontend/messages/en.json
+++ b/aleksis/apps/plank/frontend/messages/en.json
@@ -154,6 +154,7 @@
         "another_check_out": "Do another check out",
         "messages": {
           "already_added": "This item has already been added to the check out list.",
+          "no_more_available": "There are no more items available.",
           "not_found": "There is no item with this ID or barcode.",
           "checked_out": "This item is currently checked out.",
           "added": "The item has been added to the check out list."
diff --git a/aleksis/apps/plank/migrations/0020_checkedoutitem_count.py b/aleksis/apps/plank/migrations/0020_checkedoutitem_count.py
new file mode 100644
index 0000000..2cc25db
--- /dev/null
+++ b/aleksis/apps/plank/migrations/0020_checkedoutitem_count.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.1.7 on 2025-04-01 11:27
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('plank', '0019_anonymous_item'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='checkedoutitem',
+            name='count',
+            field=models.PositiveIntegerField(default=1, verbose_name='Count'),
+        ),
+    ]
diff --git a/aleksis/apps/plank/models.py b/aleksis/apps/plank/models.py
index f1fb4b8..66e80de 100644
--- a/aleksis/apps/plank/models.py
+++ b/aleksis/apps/plank/models.py
@@ -461,6 +461,7 @@ class CheckedOutItem(ExtensibleModel):
     item = models.ForeignKey(
         Item, on_delete=models.CASCADE, related_name="checked_out_items", verbose_name=_("Item")
     )
+    count = models.PositiveIntegerField(verbose_name=_("Count"), default=1)
     process = models.ForeignKey(
         CheckOutProcess,
         on_delete=models.CASCADE,
@@ -478,6 +479,11 @@ class CheckedOutItem(ExtensibleModel):
         null=True,
     )
 
+    def save(self, *args, **kwargs):
+        if self.item.is_anonymous:
+            self.count = 1
+        super().save(*args, **kwargs)
+
     class Meta:
         ordering = [
             "-process__check_in_until",
diff --git a/aleksis/apps/plank/schema.py b/aleksis/apps/plank/schema.py
index fb97e06..affee54 100644
--- a/aleksis/apps/plank/schema.py
+++ b/aleksis/apps/plank/schema.py
@@ -1,3 +1,4 @@
+from django.db import transaction
 from django.db.models import Model, Q
 from django.urls import reverse
 from django.utils.translation import gettext as _
@@ -222,7 +223,7 @@ class DeleteManufacturerMutation(DeleteMutation):
 class CheckedOutItemType(DjangoObjectType):
     class Meta:
         model = CheckedOutItem
-        fields = ("item", "id", "process", "checked_in", "checked_in_at", "checked_in_by")
+        fields = ("item", "id", "process", "count", "checked_in", "checked_in_at", "checked_in_by")
 
 
 class ItemGrapheneType(PermissionsForTypeMixin, DjangoObjectType):
@@ -456,10 +457,15 @@ class SortOrCheckMutation(graphene.Mutation):
         return SortOrCheckMutation(item=item, status=status)
 
 
+class CheckOutInputItemType(graphene.InputObjectType):
+    id = graphene.ID(required=True)
+    count = graphene.Int(required=True)
+
+
 class CheckOutInputType(graphene.InputObjectType):
     inventory = graphene.ID(required=True)
     borrowing_person = graphene.ID(required=True)
-    items = graphene.List(graphene.ID, required=True)
+    items = graphene.List(CheckOutInputItemType, required=True)
     check_out_condition = graphene.ID(required=False)
     check_in_until = graphene.Date(required=False)
 
@@ -472,35 +478,43 @@ class CheckOutMutation(graphene.Mutation):
 
     @staticmethod
     def mutate(root, info, input, **kwargs):  # noqa
-        # FIXME PERMISSION CHECK
-
-        print("START MUTATE")
-        inventory = Inventory.objects.get(pk=input.inventory)
-        print(inventory)
-        borrowing_person = Person.objects.get(pk=input.borrowing_person)
-        print(borrowing_person)
-        check_out_condition = None
-        if input.check_out_condition:
-            check_out_condition = CheckOutCondition.objects.get(pk=input.check_out_condition)
-        print(check_out_condition)
-        items = Item.objects.filter(pk__in=input.items)
-        print(items)
-        process = CheckOutProcess.objects.create(
-            inventory=inventory,
-            lending_person=info.context.user.person,
-            borrowing_person=borrowing_person,
-            condition=check_out_condition,
-            check_in_until=input.check_in_until,
-            is_check_out_in_process=False,
-        )
-        print(process)
-        checked_out_items = []
-        for item in items:
-            if not item.is_available:
-                continue
-            checked_out_item = CheckedOutItem(process=process, item=item)
-            checked_out_items.append(checked_out_item)
-        CheckedOutItem.objects.bulk_create(checked_out_items)
+        with transaction.atomic():
+            inventory = Inventory.objects.get(pk=input.inventory)
+            borrowing_person = Person.objects.get(pk=input.borrowing_person)
+            check_out_condition = None
+            if input.check_out_condition:
+                check_out_condition = CheckOutCondition.objects.get(pk=input.check_out_condition)
+
+            count_by_id = {int(item.id): item.count for item in input.items}
+            items = Item.objects.filter(pk__in=[item.id for item in input.items])
+
+            process = CheckOutProcess.objects.create(
+                inventory=inventory,
+                lending_person=info.context.user.person,
+                borrowing_person=borrowing_person,
+                condition=check_out_condition,
+                check_in_until=input.check_in_until,
+                is_check_out_in_process=False,
+            )
+
+            checked_out_items = []
+            for item in items:
+                if not info.context.user.has_perm("plank.check_out_rule", item):
+                    return
+
+                count = count_by_id[item.id] if item.is_anonymous else 1
+                if not item.is_available:
+                    continue
+                if item.is_anonymous and item.count - count < 0:
+                    continue
+
+                if item.is_anonymous:
+                    item.count -= count
+                    item.save()
+
+                checked_out_item = CheckedOutItem(process=process, item=item, count=count)
+                checked_out_items.append(checked_out_item)
+            CheckedOutItem.objects.bulk_create(checked_out_items)
         return CheckOutMutation(process=process)
 
 
diff --git a/aleksis/apps/plank/templates/plank/reports/check-out.html b/aleksis/apps/plank/templates/plank/reports/check-out.html
index 1768cd2..dfab362 100644
--- a/aleksis/apps/plank/templates/plank/reports/check-out.html
+++ b/aleksis/apps/plank/templates/plank/reports/check-out.html
@@ -31,7 +31,7 @@
   </div>
   <ul class="browser-default">
     {% for checked_out_item in process.checked_out_items.all %}
-      <li>{{ checked_out_item.item.name }} ({% if checked_out_item.item.item_type %}{{ checked_out_item.item.item_type.name }}
+      <li>{% if checked_out_item.count != 1 %}{{ checked_out_item.count }} &times; {% endif %}{{ checked_out_item.item.name }} ({% if checked_out_item.item.item_type %}{{ checked_out_item.item.item_type.name }}
         , {% endif %}{{ checked_out_item.item.barcode }})
       </li>
     {% endfor %}
-- 
GitLab


From b6d8c3e5e4e950af868b55d315f61c8640be9c5e Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Thu, 3 Apr 2025 12:16:24 +0200
Subject: [PATCH 6/6] Support check in of an. objects

---
 .../components/check_in/CheckInPage.vue       | 130 +++++++++++++++---
 .../components/check_in/checkIn.graphql       |  29 +++-
 .../components/check_out/CheckOutPage.vue     |   2 +-
 aleksis/apps/plank/frontend/messages/en.json  |   7 +-
 aleksis/apps/plank/models.py                  |  32 +++--
 aleksis/apps/plank/schema.py                  |  28 ++--
 6 files changed, 182 insertions(+), 46 deletions(-)

diff --git a/aleksis/apps/plank/frontend/components/check_in/CheckInPage.vue b/aleksis/apps/plank/frontend/components/check_in/CheckInPage.vue
index 176bdda..09c548a 100644
--- a/aleksis/apps/plank/frontend/components/check_in/CheckInPage.vue
+++ b/aleksis/apps/plank/frontend/components/check_in/CheckInPage.vue
@@ -4,28 +4,70 @@
       <v-card>
         <v-card-title>{{ $t("plank.check_in.process.title") }}</v-card-title>
         <v-card-text>
-          <ApolloMutation
-            :mutation="gqlCheckIn"
-            :variables="{ idOrBarcode }"
-            @done="checkedIn"
+          <scan-input
+            v-model="idOrBarcode"
+            :hint="$t('plank.check_in.process.scan_hint')"
+            ref="scanInput"
+            persistent-hint
+            :disabled="loading"
+            @submit="search"
+          />
+          <v-btn
+            color="primary"
+            @click="search"
+            class="mb-4"
+            :loading="loading"
+            :disabled="loading"
           >
-            <template #default="{ mutate, loading, error }">
-              <scan-input
-                v-model="idOrBarcode"
-                :hint="$t('plank.check_in.process.scan_hint')"
-                ref="scanInput"
-                persistent-hint
-                @submit="mutate"
-              />
-              <v-btn color="primary" @click="mutate" class="mb-4">
-                {{ $t("plank.check_in.process.check_in") }}
-              </v-btn>
-            </template>
-          </ApolloMutation>
+            {{ $t("plank.check_in.process.check_in") }}
+          </v-btn>
         </v-card-text>
       </v-card>
     </v-col>
 
+    <v-dialog :value="this.itemsToSelect.length > 0" persistent max-width="600">
+      <v-card>
+        <v-card-title>{{
+          $t("plank.check_in.process.select_item")
+        }}</v-card-title>
+        <v-card-text>{{
+          $t("plank.check_in.process.select_item_description")
+        }}</v-card-text>
+        <v-list two-line>
+          <v-list-item
+            v-for="item in itemsToSelect"
+            :key="item.id"
+            @click="checkIn(item.id)"
+          >
+            <v-list-item-content>
+              <v-list-item-title>
+                <template v-if="item.count !== 1">
+                  <!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
+                  {{ item.count }} &times;
+                </template>
+                <item-label :item="item.item" />
+              </v-list-item-title>
+              <v-list-item-subtitle>
+                {{
+                  $t("plank.check_in.process.checked_out_to_at", {
+                    id: item.process.id,
+                    to: item.process.borrowingPerson.fullName,
+                    at: $d(
+                      $parseISODate(item.process.checkedOutAt),
+                      "shortDateTime",
+                    ),
+                  })
+                }}
+              </v-list-item-subtitle>
+            </v-list-item-content>
+            <v-list-item-icon>
+              <v-icon>mdi-chevron-right</v-icon>
+            </v-list-item-icon>
+          </v-list-item>
+        </v-list>
+      </v-card>
+    </v-dialog>
+
     <v-col cols="12" lg="6">
       <v-card v-if="checkedOutItems.length > 0">
         <v-card-title>
@@ -38,6 +80,10 @@
             v-for="checkedOutItem in checkedOutItems"
             :key="checkedOutItem.id"
           >
+            <template v-if="checkedOutItem.count !== 1">
+              <!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
+              {{ checkedOutItem.count }} &times;
+            </template>
             {{ checkedOutItem.item.name }} (#{{ checkedOutItem.item.id }})
           </p>
         </v-card-text>
@@ -79,11 +125,14 @@
 
 <script>
 import ScanInput from "../inputs/ScanInput.vue";
-import { checkIn } from "./checkIn.graphql";
+import ItemLabel from "../item/ItemLabel.vue";
+
+import { checkIn, checkedOutItemsById } from "./checkIn.graphql";
 export default {
   name: "CheckInPage",
   components: {
     ScanInput,
+    ItemLabel,
   },
   data() {
     return {
@@ -91,7 +140,10 @@ export default {
       snackbar: false,
       snackbarStatus: "",
       checkedOutItems: [],
+      itemsToSelect: [],
       gqlCheckIn: checkIn,
+      gqlCheckedOutItemsById: checkedOutItemsById,
+      loading: false,
     };
   },
   computed: {
@@ -107,6 +159,48 @@ export default {
       this.idOrBarcode = "";
       this.focusScanInput();
     },
+    search() {
+      this.loading = true;
+      this.$apollo
+        .query({
+          query: checkedOutItemsById,
+          variables: { id: this.idOrBarcode },
+          fetchPolicy: "no-cache",
+        })
+        .then(({ data }) => {
+          console.log(data);
+          const items = data.checkedOutItemsById;
+          if (items.length === 0) {
+            this.snackbar = true;
+            this.snackbarStatus = "not_found";
+          } else if (items.length === 1) {
+            this.checkIn(items[0].id);
+          } else {
+            this.itemsToSelect = items;
+          }
+        })
+        .finally(() => {
+          this.loading = false;
+        });
+    },
+    checkIn(id) {
+      console.log("check in", id);
+      this.$apollo
+        .mutate({
+          mutation: checkIn,
+          variables: { id },
+        })
+        .then(({ data }) => {
+          console.log(data);
+          const { status, checkedOutItems } = data.checkIn;
+          this.snackbarStatus = status;
+          this.snackbar = true;
+          if (checkedOutItems?.length > 0) {
+            this.checkedOutItems = checkedOutItems;
+          }
+          this.itemsToSelect = [];
+        });
+    },
     checkedIn({ data }) {
       const { status, checkedOutItems } = data.checkIn;
       this.snackbarStatus = status;
diff --git a/aleksis/apps/plank/frontend/components/check_in/checkIn.graphql b/aleksis/apps/plank/frontend/components/check_in/checkIn.graphql
index 590edfd..5fef1df 100644
--- a/aleksis/apps/plank/frontend/components/check_in/checkIn.graphql
+++ b/aleksis/apps/plank/frontend/components/check_in/checkIn.graphql
@@ -1,7 +1,32 @@
-mutation checkIn($idOrBarcode: ID!) {
-  checkIn(idOrBarcode: $idOrBarcode) {
+query checkedOutItemsById($id: ID!) {
+  checkedOutItemsById(id: $id) {
+    id
+    count
+    process {
+      id
+      checkedOutAt
+      borrowingPerson {
+        id
+        fullName
+      }
+    }
+    item {
+      id
+      name
+      barcode
+      itemType {
+        id
+        name
+      }
+    }
+  }
+}
+
+mutation checkIn($id: ID!) {
+  checkIn(id: $id) {
     checkedOutItems {
       id
+      count
       process {
         id
         checkInForm
diff --git a/aleksis/apps/plank/frontend/components/check_out/CheckOutPage.vue b/aleksis/apps/plank/frontend/components/check_out/CheckOutPage.vue
index 335cd91..adc92bb 100644
--- a/aleksis/apps/plank/frontend/components/check_out/CheckOutPage.vue
+++ b/aleksis/apps/plank/frontend/components/check_out/CheckOutPage.vue
@@ -41,7 +41,7 @@
             <v-list>
               <template v-for="(item, index) in items">
                 <v-list-item :key="item.id">
-                  <v-list-item-icon class="mr-2">
+                  <v-list-item-icon class="mr-2" v-if="item.isAnonymous">
                     <v-text-field
                       class="count-input"
                       v-model.number="item.checkOutCount"
diff --git a/aleksis/apps/plank/frontend/messages/en.json b/aleksis/apps/plank/frontend/messages/en.json
index 33c3e53..d68701f 100644
--- a/aleksis/apps/plank/frontend/messages/en.json
+++ b/aleksis/apps/plank/frontend/messages/en.json
@@ -172,10 +172,13 @@
         "download_form": "Download check in form",
         "show_process": "Show check out process",
         "messages": {
-          "not_found": "There is no item with this ID or barcode.",
+          "not_found": "There is no checked out item with this ID or barcode.",
           "checked_in": "The item has been successfully checked in.",
           "not_checked_out": "The item is not checked out."
-        }
+        },
+        "select_item": "Select an item",
+        "select_item_description": "There are multiple check outs for this item (e. g. because it's anonymous). Please select the one you want to check in.",
+        "checked_out_to_at": "Checked out to {to} at {at} (#{id})"
       }
     },
     "check_out_process": {
diff --git a/aleksis/apps/plank/models.py b/aleksis/apps/plank/models.py
index 66e80de..31ab9c0 100644
--- a/aleksis/apps/plank/models.py
+++ b/aleksis/apps/plank/models.py
@@ -1,7 +1,7 @@
 from typing import Optional
 
-from django.db import models
-from django.db.models import Max
+from django.db import models, transaction
+from django.db.models import Max, QuerySet
 from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 
@@ -308,10 +308,15 @@ class Item(ExtensibleModel):
             return self.count > 0
         return not self.current_checked_out_item
 
+    @property
+    def current_checked_out_items(self) -> QuerySet["CheckedOutItem"]:
+        """Get current check outs for this item."""
+        return self.checked_out_items.filter(checked_in=False)
+
     @property
     def current_checked_out_item(self) -> Optional["CheckedOutItem"]:
         """Get current check out for this item (if checked out)."""
-        qs = self.checked_out_items.filter(checked_in=False)
+        qs = self.current_checked_out_items
         if qs.exists():
             return qs.first()
         return None
@@ -342,16 +347,6 @@ class Item(ExtensibleModel):
             except cls.DoesNotExist:
                 return None
 
-    def check_in(self, person: Person) -> "CheckedOutItem":
-        """Check in item."""
-        checked_out_item = self.current_checked_out_item
-        if checked_out_item:
-            checked_out_item.checked_in = True
-            checked_out_item.checked_in_at = timezone.now()
-            checked_out_item.checked_in_by = person
-            checked_out_item.save()
-        return checked_out_item
-
 
 class CheckOutCondition(ExtensibleModel):
     """A condition to which an item is checked out."""
@@ -479,6 +474,17 @@ class CheckedOutItem(ExtensibleModel):
         null=True,
     )
 
+    def check_in(self, person: Person):
+        """Check in item."""
+        with transaction.atomic():
+            if self.item.is_anonymous:
+                self.item.count += self.count
+                self.item.save()
+            self.checked_in = True
+            self.checked_in_at = timezone.now()
+            self.checked_in_by = person
+            self.save()
+
     def save(self, *args, **kwargs):
         if self.item.is_anonymous:
             self.count = 1
diff --git a/aleksis/apps/plank/schema.py b/aleksis/apps/plank/schema.py
index affee54..d7b326d 100644
--- a/aleksis/apps/plank/schema.py
+++ b/aleksis/apps/plank/schema.py
@@ -520,25 +520,25 @@ class CheckOutMutation(graphene.Mutation):
 
 class CheckInMutation(graphene.Mutation):
     class Arguments:
-        id_or_barcode = graphene.ID(required=True)
+        id = graphene.ID(required=True)
 
     checked_out_items = graphene.List(CheckedOutItemType)
     status = graphene.Field(graphene.String)
 
     @staticmethod
-    def mutate(root, info, id_or_barcode, **kwargs):  # noqa
-        item = Item.get_by_id_or_barcode(id_or_barcode)
-        if not item:
+    def mutate(root, info, id, **kwargs):  # noqa
+        checked_out_item = CheckedOutItem.objects.get(pk=id)
+        if not checked_out_item:
             return CheckInMutation(status="not_found")
-        if not info.context.user.has_perm("plank.check_in_item_rule", item):
+        if not info.context.user.has_perm("plank.check_in_item_rule", checked_out_item.item):
             return
-        if item.is_available:
+        if checked_out_item.checked_in:
             return CheckInMutation(status="not_checked_out")
 
-        checked_out_items = [item.current_checked_out_item]
-        item.check_in(person=info.context.user.person)
-        for contained_item in item.contained_items:
-            if not contained_item.is_available:
+        checked_out_items = [checked_out_item]
+        checked_out_item.check_in(person=info.context.user.person)
+        for contained_item in checked_out_item.item.contained_items:
+            if not contained_item.is_available and not contained_item.is_anonymous:
                 checked_out_items.append(contained_item.current_checked_out_item)
                 contained_item.check_in(person=info.context.user.person)
 
@@ -563,6 +563,7 @@ class Query:
     items = graphene.List(ItemGrapheneType)
     item_by_id = graphene.Field(ItemGrapheneType, id=graphene.ID(required=True))
     check_out_items_by_id = graphene.Field(CheckedOutItemsType, id=graphene.ID(required=True))
+    checked_out_items_by_id = graphene.List(CheckedOutItemType, id=graphene.ID(required=True))
     check_out_conditions = graphene.List(CheckOutConditionType)
     check_out_processes = graphene.List(CheckOutProcessType)
     check_out_process_by_id = graphene.Field(CheckOutProcessType, id=graphene.ID(required=True))
@@ -630,6 +631,13 @@ class Query:
 
         return CheckedOutItemsType(item=item, contained_items=item.contained_items)
 
+    def resolve_checked_out_items_by_id(self, info, id, **kwargs):  # noqa
+        item = Item.get_by_id_or_barcode(id)
+        if not item or not info.context.user.has_perm("plank.view_item_rule", item):
+            return None
+
+        return item.current_checked_out_items
+
     def resolve_item_types(self, info, permission=None, **kwargs):
         if not info.context.user.has_perm("plank.view_itemtype"):
             return ItemType.objects.filter(
-- 
GitLab