دراسة حالة · شركة الخنيني العالمية المحدودة · يوليو 2023 – الآن
النظام الذي يعرف مَن بيده الجهاز.
منصة داخلية لعهدة أصول تقنية المعلومات: ما الذي تملكه الشركة، ومن يحمله الآن، ومن وقّع على استلامه. مبنية على جانغو وبوستجريس، لتحل محل منتج قديم وجداول بيانات كانت عدة إدارات تحدّثها يدويًا.
المستودع خاص: فيه سجلات موظفين وأرقام هويات وتوقيعات ممسوحة، لذلك هذه الصفحة هي التصميم لا الشيفرة: المخطط، وكيف تُكتب عملية تسليم العهدة، والقراران اللذان أدافع عنهما، والقرار الذي كنت سأعكسه.
كان هناك مصدران للحقيقة نفسها. تطبيق قديم يحمل جزءًا من جرد الأصول، وجداول بيانات تحمل الباقي، إضافة إلى كل ما لم يكن المنتج القديم قادرًا على تمثيله، وهو يشمل الشيء الوحيد الذي كان الناس يحتاجون فعلًا إلى البحث عنه: مَن بيده جهاز معيّن الآن.
والنتائج كانت متوقعة. الأصل نفسه قد يوجد مرتين تحت وسمين مختلفين، وقد يغادر موظف دون أن يُسجَّل في أي مكان أنه أعاد عهدته، وحين يُفقد حاسب محمول، لم يكن هناك مستند عليه توقيع يقول من الذي قبِل المسؤولية عنه.
وهذه النقطة الأخيرة هي التي شكّلت التصميم كله. هذا ليس نظام جرد بالضبط. إنه سجل عهدة، ووظيفته أن يثبت سلسلة الحيازة بعد وقوع الحدث.
ستة جداول. أهمها ASSETASSIGNMENT، وهو ليس جدول ربط
متعدد-إلى-متعدد بل حدث له خصائصه: متى بدأ، ومتى انتهى، وصورتا توقيع.
الأصل الذي ليس له سجل عهدة مفتوح موجود في المستودع؛ والذي له سجل
مفتوح في يد أحدهم.
on_delete هي التصميم نفسه: كل ما يحمل تاريخ عهدة
PROTECT، وكل ما هو تنظيمي SET_NULL، وقيمة
returned_at الفارغة تعني أن العهدة ما زالت مفتوحة.
السجلات تُغلق ولا تُحذف، فتاريخ عهدة أي أصل يُستخرج باستعلام واحد.
لماذا يرفض نصف المفاتيح الأجنبية الحذف
Asset.brand وAsset.category
وAssetAssignment.asset
وAssetAssignment.employee كلها
on_delete=PROTECT، أما Employee.project
وAssetAssignment.project فهما SET_NULL.
هذا الفصل مقصود، وهو القرار الذي أدافع عنه أولًا.
لا يمكنك حذف موظف سبق أن وقّع على استلام جهاز. توقيعه هو الدليل، وحفظه هو
كل ما وُجد النظام من أجله، فالموظف الذي يغادر يصبح is_active = False؛ و23
من أصل 933 سجلًا مُعلَّمة غير نشطة، ولم يُحذف أي منها، أما المشاريع فمختلفة: انتهاء
مشروع أمر طبيعي، ولا يصح أن يأخذ معه تاريخ عهدة الأصول، فتصبح تلك
المراجع فارغة ويبقى سجل العهدة.
والنتيجة العملية أن التطبيق يكاد لا يملك مسارات حذف، وأن قاعدة البيانات هي التي تفرض ذلك بدل الوثوق بأن التطبيق سيتذكّره، وهذا كان المقصد من ترك جداول البيانات: قاعدة لا يستطيع أحد تخطّيها بالخطأ.
يجب ألّا يكون الأصل الواحد مفتوحًا لشخصين في وقت واحد. هذا هو الثابت الأساسي، وأول محاولاتي لفرضه كانت خاطئة بطريقة تستحق التدوين.
الترحيل الأول أعلن ما يلي:
كان المقصود: لا يمكن أن يكون للأصل أكثر من سجل واحد قيمة
returned_at فيه فارغة، والمشكلة أن هذا لا يعمل، ولا
يعمل لسبب يسهل قوله ويسهل نسيانه، ففي إس كيو إل، القيمة
الفارغة لا تساوي القيمة الفارغة. الفهرس الفريد يعامل كل
قيمة فارغة على أنها مختلفة، فلا يتعارض
(الأصل 42، فارغ) مع
(الأصل 42، فارغ). أي أن القيد كان يسمح تحديدًا
بالحالة التي كُتب ليمنعها، وفي الوقت نفسه قد يعترض على سجلات مشروعة
تصادف أن لها الطابع الزمني نفسه للإرجاع.
والأداة الصحيحة هي الفهرس الفريد الجزئي، قاعدة تفرّد تنطبق على مجموعة مُرشَّحة من السجلات فقط، في بوستجريس
تُكتب CREATE UNIQUE INDEX … WHERE returned_at IS NULL،
وفي جانغو تُكتب هكذا:
ولأكون صريحًا بشأن الوضع الحالي: هذا القيد ليس في قاعدة البيانات بعد. منذ الترحيل 0007 وحتى الآن، كان الثابت مفروضًا من مسار التطبيق وحده، إذ يبحث مسار التسليم عن سجل عهدة مفتوح ويغلقه قبل إنشاء التالي، وهو ما يعمل إلى أن يقوم طلبان بذلك في اللحظة نفسها، وهو التغيير التالي الذي أنفّذه، ويأتي مع إصلاح المعاملة المذكور في الثغرات المعروفة أدناه.
والترحيل سيُطبَّق دون مشاكل. راجعت سجلات العهدة المفتوحة الـ428 جميعها ولا يوجد أصل مفتوح أكثر من مرة، فليس هناك ما يحتاج تسوية أولًا، غير أني لا أعدّ ذلك أمانًا، فالشخص الواحد الذي يستخدم أداة داخلية على شبكة محلية نادرًا ما يتسابق مع نفسه.
يتم تسليم الأصول عبر معالج خطوات: اختيار الموظف، ثم أصل واحد أو أكثر، ثم توقيع الطرفين على الشاشة، ثم التأكيد، وخطوة التأكيد هي حيث تعيش كل القواعد.
توقيعان، ومسألة عالقة في جانغو
كل سجل عهدة يحمل صورتين: الموظف وهو يستلم الجهاز، والجهة المخوَّلة
وهي تفرج عنه، ولا واحدة منهما اختيارية، وكلتاهما تُلتقط على لوحة
توقيع في المتصفح، وهناك صلاحية منفصلة
view_signatures تتحكم بمن يستطيع الاطلاع عليهما لاحقًا، فهي تواقيع أشخاص حقيقيين.
وحفظهما كشف ثغرة صغيرة في جانغو. الدالة المُمرَّرة إلى
upload_to تستقبل كائن النموذج واسم الملف، لكنها لا
تستقبل أي حقل استدعاها، فمع وجود حقلَي
ImageField على نموذج واحد لا تستطيع التمييز بين توقيع
الموظف وتوقيع الجهة المخوَّلة، وسيقع كلاهما على المسار نفسه، والحل
الذي اتبعته هو ضبط خاصية مؤقتة على الكائن قبل كل عملية حفظ، وقراءتها
داخل الدالة لاختيار اسم الملف، ثم حذفها بعد ذلك:
قوالب جانغو مُصيَّرة على الخادم، بلا إس بي إيه، وبلا خطوة بناء، وبلا إطار جافاسكريبت، والـجافاسكريبت الحقيقي الوحيد هو لوحة التوقيع والنافذة المنبثقة التي تستضيف المعالجات، وكل مدخل في الشريط الجانبي مضبوط بصلاحية، فحسابان في مجموعتين مختلفتين يريان قائمتين مختلفتين.
| # ⇅ | Asset id ⇅ | Name ⇅ | S/N ⇅ | Brand ⇅ | Model ⇅ | Status | Emp no |
|---|---|---|---|---|---|---|---|
| 1 | 0412 | Desktop | XXXXXXX | Brand A | Model 1000 | Available | – |
| 2 | 0413 | Laptop | XXXXXXX | Brand B | Model 2000 | Assigned | 0091 |
| 3 | 0414 | Monitor 24" | XXXXXXX | Brand A | Model 24H | Assigned | 0143 |
| 4 | 0415 | Printer | XXXXXXX | Brand C | Model 7740 | Available | – |
أمران إداريان، لأن سجل عهدة يفقد تاريخه أسوأ من عدم وجود سجل أصلًا.
backup_core يكتب ثلاثة مخرجات؛ وrestore_core
يعيدها، ويمكن تخطي كل جزء على حدة بـ --no-src أو
--no-db أو --no-signatures.
--exclude موجودان بسبب عملية
استعادة فشلت، فـdumpdata
يُسلسِل contenttypes وauth.Permission، ثم
يتعارض loaddata مع السجلات التي أنشأتها ترحيلات جانغو
نفسها في قاعدة البيانات الجديدة، واستثناؤهما، مع استخدام المفاتيح
الطبيعية ليصمد ارتباط المفاتيح الأجنبية عبر تسلسلات مفاتيح مختلفة،
هو ما يجعل الرحلة ذهابًا وإيابًا تعمل فعلًا.
أعرف هذا لأن أول عملية استعادة فشلت بخطأ في تكامل البيانات، واستغرق فهم السبب وقتًا، ويعني ذلك أيضًا أن الجواب الصادق على سؤال «هل سبق أن استعدت نسخة احتياطية؟» هو نعم، أكثر من مرة، وأعرف أي الخيارات تهم.
سأغيّره
1. كان يجب أن يكون model جدولًا
brand وcategory مفتاحان أجنبيان إلى جداول
مرجعية، أما model فحقل CharField نصي حر،
وفي حينه بدا ذلك عمليًا، فطُرز الأجهزة بالآلاف ولم أرغب في
صيانة فهرس لها، لكن ما اشتراه ذلك فعلًا هو التالي:
OPTIPLEX-7000 وOptiPlex 7000
وOptiplex7000 وOPTI-PLEX 7000
وOPTIPOLEX 7000 وOptiolex 7000 tower.
جاء معظمها مع استيراد جداول البيانات القديمة، وكُتب الباقي يدويًا
بعد ذلك، لأن لا شيء كان يمنعه.
والضرر العملي أنك لا تستطيع الإجابة عن سؤال «كم لدينا من OptiPlex
7000؟» بـGROUP BY، وهو تحديدًا نوع السؤال الذي بُني
النظام للإجابة عنه، فالتجميع على model يعيد 255 مجموعة
لثلاثين جهازًا حقيقيًا تقريبًا.
وكشفت مراجعتها صنفًا ثانيًا أقبح من الخلل: مع غياب أي تحقق على عمود نصي حر، تنتهي القيم في العمود الخطأ تمامًا.
CharField بلا مُتحقِّق يقبل أي شيء، وبعد 684
سجلًا تكتشف ما الذي كان يعنيه «أي شيء».
ولذلك للإصلاح نفسه شق ثانٍ: مُتحقِّقات وفحوص صيغة على الأعمدة ذات
الشكل المعروف (الهوية الوطنية، ورقم الجوال، والرقم التسلسلي)،
مفروضة على مستوى قاعدة البيانات بـCheckConstraint
لا في نموذج إدخال فقط، حتى لا يستطيع الاستيراد الجماعي القادم
الالتفاف عليها كما فعل السابق.
2. نموذج المستخدم المخصص، أُضيف ثم أُزيل
الترحيل 0013 أدخل نموذج User مخصصًا يرث
AbstractUser، بصلاحيتَي edit_users
وview_users_readonly، والترحيل 0014 حذفه مرة أخرى.
والدرس خاص بـجانغو وموثّق جيدًا، وتعلّمته رغم ذلك: تبديل
AUTH_USER_MODEL قرار تتخذه عمليًا قبل الترحيل الأول لا
بعد ثلاثة عشر، فما إن تصبح لجداول أخرى مفاتيح أجنبية إلى
auth.User حتى يتحول تغييره إلى مشكلة ترحيل لا تتناسب مع
الفائدة، وما كنت أريده فعلًا هو صلاحيات مخصصة، وهي لا تتطلب نموذج
مستخدم مخصصًا، ومن هنا جاءت الصلاحيتان على
AssetAssignment.
ولا يزال أثران من هذا الالتفاف في الشيفرة: استيراد
AbstractUser غير المستخدم في أعلى
models.py، وتعليق قديم بجانب
AUTH_USER_MODEL. تركتهما إلى أن أكتب هذا بصدق؛ ويجب أن
يُحذفا.
أشياء أعرف أنها خاطئة ولم أصلحها بعد. ذكرها أرخص من أن تُسأل عنها.
-
مسار التسليم ليس ضمن معاملة.
unassign_confirmيعمل داخلtransaction.atomic()، أماassign_confirmفلا، إذ يمر على الأصول المختارة، فيغلق سجل عهدة قائمًا وينشئ آخر جديدًا في كل دورة، بقراءة ثم كتابة وبلا قفل على السجل، ولذلك نتيجتان: طلبان متزامنان على الأصل نفسه قد ينشئان سجلَي عهدة مفتوحين، وفشل في منتصف تسليم متعدد الأصول يترك الأصول الأولى مثبَّتة والباقي لا، والإصلاح هو الفهرس الفريد الجزئي أعلاه، معatomic()حول الحلقة، وselect_for_update()على استعلام سجل العهدة المفتوح. -
لا توجد اختبارات. ملف
core/tests.pyما زال الملف المكوَّن من خمسة أسطر الذي أنشأهstartapp، ولنظام قيمته كلها ثابت واحد يخص العهدة، فإن أول اختبار ينبغي كتابته واضح، وغيابه أقل ما في هذه الصفحة قابلية للدفاع. -
نظافة البيانات المرجعية. إضافة إلى
model: 528 من أصل 684 أصلًا بلا وسم خدمة، وعمودinvoice_numberعمود قديم حلّ محلهvendorولا يزال يحمل محتواه السابق، والقيم البديلة في شكل 9 كلها ما زالت موجودة. -
بقايا من التفافة نموذج المستخدم. استيراد
AbstractUserغير مستخدم، وتعليق قديم بجانبAUTH_USER_MODEL. أمر تافه، لكنه من النوع الذي يجعل القارئ يتساءل عمّا تُرك ناقصًا غيره.
والترتيب مقصود: ثغرة التزامن والاختبارات الغائبة مهمة واحدة، لأن الاختبار الذي يثبت أن الأصل لا يمكن أن يكون في عهدة شخصين هو نفسه الاختبار الذي يثبت أن الإصلاح نجح.