งูการ์ตูนสีเขียวถือห่วงพวงกุญแจที่ร้อยป้ายสิทธิ์สีฟ้ารูปหนังสือ สีเหลืองรูปดินสอ และสีเขียวรูปเครื่องหมายถูก ข้างกล่องสีส้มที่มีป้ายถังขยะสีแดงแยกไว้

จัดชุดสิทธิ์เอกสารด้วย enum.Flag ใน Python พร้อมเทสต์

น้องกายEngineering

ระบบเอกสารสำนักงานมักเริ่มจาก 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 แล้วแปลงกลับก่อนใช้ พร้อมรักษาความหมายของบิต
  • เทสต์ทั้งอนุญาต ปฏิเสธ ได้บางสิทธิ์ ชุดว่าง และค่าจากภายนอก