
จัดชุดสิทธิ์เอกสารด้วย enum.Flag ใน Python พร้อมเทสต์
ระบบเอกสารสำนักงานมักเริ่มจาก can_read กับ can_write แล้วค่อยเพิ่มสิทธิ์ลบและอนุมัติตามงานจริง จนต้องส่ง boolean หลายตัวผ่านหลายฟังก์ชัน แก้ทีหนึ่งต้องไล่ดูว่าตัวไหนหมายถึงอะไร ผมจะใช้ enum.Flag จัดสิทธิ์ที่เปิดพร้อมกันได้ให้เป็นค่าชุดเดียว พร้อมเทสต์จุดที่ทำให้ระบบเผลออนุญาตผิดครับ
WHY: ให้ชุดสิทธิ์อ่านรู้เรื่องและตรวจซ้ำได้
Permission.READ | Permission.WRITEบอกเจตนาตรงกว่าตัวเลข3ที่ไม่มีคำอธิบาย- รวมสิทธิ์และตรวจสิทธิ์ด้วยรูปแบบเดียวกัน ลดการเขียนเงื่อนไขซ้ำตามหน้าจอและบริการ
- กำหนดชื่อชุดอย่าง
ALLไว้จุดเดียว แล้วใช้ร่วมกันได้ทั้งโค้ดงานและเทสต์ - แปลงเป็นจำนวนเต็มที่ขอบเขตฐานข้อมูล แล้วคืนเป็นชนิดสิทธิ์ก่อนใช้ตัดสินใจ
Enum คือกลุ่มชื่อค่าคงที่ เช่นสถานะเอกสารที่เลือกหนึ่งค่า ส่วน Flag เหมาะกับตัวเลือกที่รวมกันได้ เช่นคนเดียวอ่านและเขียนได้พร้อมกัน แนวทางนี้อยู่ใน Enum HOWTO ชื่อสิทธิ์ช่วยให้โค้ดชัดขึ้น แต่กฎว่าใครควรได้สิทธิ์อะไรยังเป็นหน้าที่ของระบบเรา
ทำงานยังไง
Bitmask คือจำนวนเต็มที่ใช้แต่ละบิตแทนตัวเลือกหนึ่งอย่าง คิดเหมือนพวงกุญแจสำนักงาน กุญแจอ่าน เขียน ลบ และอนุมัติอยู่ในพวงเดียวกันได้ การมีดอกหนึ่งไม่ได้แปลว่าเปิดได้ทุกห้องครับ
เมื่อใช้ auto() กับ Flag ค่าสิทธิ์พื้นฐานในตัวอย่างจะเป็น 1, 2, 4, 8 ซึ่งเป็นกำลังของสอง แต่ละค่าจึงใช้คนละบิต เครื่องหมาย | รวมบิต ส่วน & เลือกเฉพาะบิตที่มีร่วมกัน ตามแนวทางใน เอกสาร enum
ใช้ required in granted ตรวจว่าชุดที่ต้องการอยู่ครบในชุดที่ได้รับ หรือเขียน (granted & required) == required ก็ได้ ระวังว่าการใช้ bool(granted & required) ถามเพียงว่ามีสิทธิ์ตรงกันอย่างน้อยหนึ่งตัว จึงใช้แทนการตรวจครบทุกสิทธิ์ไม่ได้
เราจะตั้ง NONE = 0 และ ALL = READ | WRITE | DELETE | APPROVE เป็นชื่อของชุดว่างกับชุดรวม ในศัพท์ของ Flag ค่าศูนย์และค่าหลายบิตจัดเป็น alias หรือชื่อแทน การวนผ่านคลาสจึงไม่คืนสองชื่อนี้ ส่วน __members__ มีชื่อที่ประกาศครบ ตาม หัวข้อ Iteration เทสต์ด้านล่างจะยืนยันทั้งการวนผ่านคลาสและผ่านค่าชุดสิทธิ์ด้วย
โค้ดตัวอย่างพร้อมเทสต์
บันทึกเป็น permissions_test.py แล้วรัน python3 -I -B permissions_test.py ตัวอย่างนี้สมมติใบเบิกค่าอุปกรณ์ของคุณมะลิวันที่ 19 ตุลาคม 2026 ยอด 1,250 บาท กฎตัวอย่างกำหนดว่าผู้อนุมัติต้องมีทั้งอ่านและอนุมัติ โดยแยกฟังก์ชันตรวจทั่วไปออกจากกฎของงานนี้
from enum import Flag, IntFlag, auto
import unittest
class Permission(Flag):
NONE = 0
READ = auto()
WRITE = auto()
DELETE = auto()
APPROVE = auto()
ALL = READ | WRITE | DELETE | APPROVE
def has_all(granted: Permission, required: Permission) -> bool:
return required in granted
def can_approve(granted: Permission) -> bool:
return has_all(granted, Permission.READ | Permission.APPROVE)
class PermissionTests(unittest.TestCase):
def setUp(self):
self.document = {
"owner": "มะลิ",
"date": "2026-10-19",
"amount_baht": 1250,
}
self.editor = Permission.READ | Permission.WRITE
def test_combine_and_check(self):
self.assertIn(Permission.READ, self.editor)
self.assertNotIn(Permission.DELETE, self.editor)
required = Permission.READ | Permission.APPROVE
self.assertFalse(has_all(self.editor, required))
self.assertEqual(self.editor & required, Permission.READ)
self.assertTrue(bool(self.editor & required)) # ตรงเพียงบางสิทธิ์
manager = Permission.READ | Permission.APPROVE
self.assertTrue(can_approve(manager))
self.assertFalse(can_approve(Permission.APPROVE))
self.assertEqual(manager & required, required)
self.assertEqual(self.document["amount_baht"], 1250)
def test_auto_aliases_and_iteration(self):
basic = [Permission.READ, Permission.WRITE,
Permission.DELETE, Permission.APPROVE]
self.assertEqual([p.value for p in basic], [1, 2, 4, 8])
self.assertEqual(list(Permission), basic)
self.assertEqual(list(self.editor), basic[:2])
self.assertEqual(list(Permission.ALL), basic)
self.assertIs(Permission.__members__["ALL"], Permission.ALL)
self.assertIn("NONE", Permission.__members__)
self.assertEqual(Permission.ALL.value, 15)
def test_empty_permissions(self):
empty = Permission(0)
self.assertEqual(empty, Permission.NONE)
self.assertFalse(bool(empty))
self.assertEqual(list(empty), [])
self.assertIn(Permission.NONE, self.editor)
self.assertTrue(has_all(empty, Permission.NONE))
self.assertFalse(can_approve(empty))
def test_database_round_trip(self):
row = {"name": self.document["owner"],
"permission_mask": self.editor.value}
self.assertIs(type(row["permission_mask"]), int)
restored = Permission(row["permission_mask"])
self.assertEqual(restored, self.editor)
with self.assertRaises(ValueError):
Permission(16) # บิตนี้ไม่ได้ประกาศไว้
def test_flag_and_intflag_boundary(self):
with self.assertRaises(TypeError):
_ = self.editor | 1
class LegacyPermission(IntFlag):
READ = 1
WRITE = 2
self.assertEqual(LegacyPermission.READ, 1)
self.assertEqual(LegacyPermission.READ | 2,
LegacyPermission.READ | LegacyPermission.WRITE)
self.assertEqual(LegacyPermission(4).value, 4)
self.assertIs(type(LegacyPermission.READ + 1), int)
if __name__ == "__main__": unittest.main()
ผมรันโค้ดนี้ด้วย Python 3.14.7 แล้ว เทสต์ทั้ง 5 รายการผ่านครับ
ในตัวอย่าง row จำลองข้อมูลที่จะส่งให้ฐานข้อมูล ยังไม่ได้เชื่อมต่อฐานข้อมูลจริง เราเก็บ .value เป็นจำนวนเต็ม แล้วใช้ Permission(number) แปลงกลับก่อนตรวจสิทธิ์ พฤติกรรมเรียกคลาสด้วยค่าเพื่อค้นสมาชิกและการอ่าน .value อธิบายใน enum reference ส่วนคอลัมน์และคำสั่งบันทึกต้องเลือกตามฐานข้อมูลที่ใช้งาน
การแปลงกลับไม่ได้พิสูจน์ว่าผู้ส่งข้อมูลมีสิทธิ์นั้นจริง จึงควรอ่านค่าที่ระบบกำหนดให้ผู้ใช้จากแหล่งที่เชื่อถือได้ แล้วตรวจที่ฝั่งบริการก่อนทำรายการ การซ่อนปุ่มอนุมัติช่วยเรื่องหน้าจอ แต่กฎอนุมัติในตัวอย่างควรอยู่ตรงทางเข้าการทำงานด้วย
จุดที่คนพลาดบ่อย
- ใช้
andหรือorรวมสิทธิ์: สองตัวนี้เป็นตัวดำเนินการตรรกะ แก้เป็น|สำหรับรวม และ&สำหรับหาส่วนร่วม ใส่วงเล็บในเงื่อนไขให้เห็นชัดว่ากำลังเทียบชุดไหน - ตรวจหลายสิทธิ์แล้วผ่านเพราะตรงแค่ตัวเดียว:
bool(granted & required)ไม่รับประกันว่าครบ แก้เป็นrequired in grantedหรือเทียบผล&กับrequiredเทสต์ผู้ใช้ที่มีเพียงบางสิทธิ์เสมอ - ใช้
NONE in grantedถามว่าไม่มีสิทธิ์หรือยัง: เทสต์ยืนยันว่าชุดว่างอยู่ในชุดอื่นได้ แก้เป็นgranted == Permission.NONEหรือnot grantedและกำหนดให้ชัดว่าฟังก์ชันตรวจชุดว่างควรยอมรับหรือปฏิเสธ ตัวอย่างนี้ให้การขอศูนย์สิทธิ์ผ่าน - คิดว่า
FlagกับIntFlagสลับกันได้เลย: เทสต์ยืนยันว่าFlagชุดนี้รวมกับเลขตรง ๆ ไม่ได้ แต่IntFlagรวมกับจำนวนเต็มได้ และบวกเลขแล้วได้intอีกทั้งค่าบิตนอกชุดที่ทดสอบถูกปฏิเสธในFlagแต่คงอยู่ในIntFlagแก้โดยใช้Flagเป็นค่าเริ่มต้นสำหรับงานนี้ และเลือกIntFlagเมื่อจำเป็นต้องเข้ากับระบบที่รับจำนวนเต็มจริง ๆ - สลับลำดับ
auto()หลังเก็บเลขลงฐานข้อมูลแล้ว: เลขเดิมอาจเปลี่ยนความหมาย แก้โดยตรึงค่าบิตเป็นตัวเลขชัดเจนเมื่อเริ่มใช้เป็นรูปแบบข้อมูลถาวร ห้ามนำบิตเก่ากลับมาใช้ต่างความหมาย และเมื่อเพิ่มสิทธิ์ใหม่ให้ปรับALLด้วย เพราะชื่อชุดนี้รวมเฉพาะสมาชิกที่เขียนไว้
ใช้ตรงไหนในงานจริง
- ระบบเอกสาร: แยกคนอ่าน คนแก้ และคนอนุมัติ โดยตรวจชุดสิทธิ์ก่อนเปลี่ยนเอกสาร
- หน้าจอจัดการผู้ใช้: วนผ่าน
Permissionเพื่อสร้างรายการสิทธิ์พื้นฐาน แล้วใช้ตารางชื่อภาษาไทยประกอบการแสดงผล - งานนำเข้าข้อมูลเดิม: แปลงจำนวนเต็มเป็น
Permissionที่ขอบเขตระบบ และจัดการค่าที่แปลงไม่ผ่านก่อนส่งเข้ากฎงาน - บันทึกเหตุการณ์ตรวจสอบ: แสดงชื่อจากการวนผ่านชุดสิทธิ์ เพื่อให้เจ้าหน้าที่อ่านได้ว่าเปิดสิทธิ์ใดอยู่ โดยเก็บบริบทผู้ทำรายการและเอกสารประกอบด้วย
Flag เหมาะกับสิทธิ์ที่รวมกันได้อย่างอิสระ ถ้ากฎเริ่มเป็น “อนุมัติได้เฉพาะสาขาของตัวเองและวงเงินไม่เกินที่กำหนด” ให้ตรวจสาขาและวงเงินเพิ่มในกฎงาน อย่ายัดทุกเงื่อนไขให้กลายเป็นบิตใหม่ เพราะชุดสิทธิ์ไม่ได้เก็บบริบทเหล่านั้นให้เรา
เทียบกับทางเลือกอื่น
| แนวทาง | ดีตรงไหน | ระวังอะไร |
|---|---|---|
| boolean หลายตัว | ตรงไปตรงมาเมื่อมีตัวเลือกน้อย | เพิ่มสิทธิ์แล้วต้องตามแก้หลายจุด |
set[str] | อ่านชื่อได้และเพิ่มรายการยืดหยุ่น | ต้องตรวจชื่อที่ยอมรับและคำสะกดเอง |
Flag | รวมชุดและตรวจสิทธิ์ด้วยชื่อในชนิดเดียว | ต้องรักษาค่าบิตเมื่อเก็บเป็นข้อมูลถาวร |
IntFlag | เชื่อมกับส่วนที่ใช้จำนวนเต็มสะดวก | ต้องระวังเลขทั่วไปและบิตนอกชุดตามเทสต์ |
ผมเลือก Flag เมื่อสิทธิ์มีรายการชัดเจนและต้องใช้ชุดเดิมร่วมกันหลายส่วน ถ้าโจทย์ต้องเพิ่มชื่อสิทธิ์จากหน้าจอได้ตลอดเวลา ควรออกแบบข้อมูลสิทธิ์ให้รองรับโจทย์นั้นก่อนเลือกโครงสร้างในโค้ด
Recap
- ใช้
Flagแทนตัวเลือกที่เปิดพร้อมกันได้ - ให้
auto()สร้างบิตพื้นฐาน และตั้งชื่อชุดรวมอย่างALL - รวมด้วย
|ตรวจครบด้วยinหรือเทียบผล& - เช็กชุดว่างด้วย
not grantedหรือเทียบกับNONE - จำว่าการวนผ่านคลาสข้าม alias แต่
__members__มีชื่อครบ - เก็บ
.valueแล้วแปลงกลับก่อนใช้ พร้อมรักษาความหมายของบิต - เทสต์ทั้งอนุญาต ปฏิเสธ ได้บางสิทธิ์ ชุดว่าง และค่าจากภายนอก