#!/usr/bin/env python3 """ Document Version Control for Quality Documentation Manages document lifecycle for quality manuals, SOPs, work instructions, and forms. Tracks versions, approvals, revisions, change history, electronic signatures per 21 CFR Part 11. Features: - Version numbering (Major.Minor.Edit, e.g., 2.1.3) - Change control with impact assessment - Review/approval workflows - Electronic signature capture - Document distribution tracking - Training record integration - Expiry/obsolete management Usage: python document_version_control.py --create new_sop.md python document_version_control.py --revise existing_sop.md --reason "Regulatory update" python document_version_control.py --status python document_version_control.py --matrix --output json """ import argparse import json import os import hashlib from dataclasses import dataclass, field, asdict from typing import List, Dict, Optional, Tuple from datetime import datetime, timedelta from pathlib import Path import re @dataclass class DocumentVersion: """A single document version.""" doc_id: str title: str version: str revision_date: str author: str status: str # "Draft", "Under Review", "Approved", "Obsolete" change_summary: str = "" next_review_date: str = "" approved_by: List[str] = field(default_factory=list) signed_by: List[Dict] = field(default_factory=list) # electronic signatures attachments: List[str] = field(default_factory=list) checksum: str = "" template_version: str = "1.0" @dataclass class ChangeControl: """Change control record.""" change_id: str document_id: str change_type: str # "New", "Revision", "Withdrawal" reason: str impact_assessment: Dict # Quality, Regulatory, Training, etc. risk_assessment: str notifications: List[str] effective_date: str change_author: str class DocumentVersionControl: """Manages quality document lifecycle and version control.""" VERSION_PATTERN = re.compile(r'^(\d+)\.(\d+)\.(\d+)$') DOCUMENT_TYPES = { 'QMSM': 'Quality Management System Manual', 'SOP': 'Standard Operating Procedure', 'WI': 'Work Instruction', 'FORM': 'Form/Template', 'REC': 'Record', 'POL': 'Policy' } def __init__(self, doc_store_path: str = "./doc_store"): self.doc_store = Path(doc_store_path) self.doc_store.mkdir(parents=True, exist_ok=True) self.metadata_file = self.doc_store / "metadata.json" self.documents = self._load_metadata() def _load_metadata(self) -> Dict[str, DocumentVersion]: """Load document metadata from storage.""" if self.metadata_file.exists(): with open(self.metadata_file, 'r', encoding='utf-8') as f: data = json.load(f) return { doc_id: DocumentVersion(**doc_data) for doc_id, doc_data in data.items() } return {} def _save_metadata(self): """Save document metadata to storage.""" with open(self.metadata_file, 'w', encoding='utf-8') as f: json.dump({ doc_id: asdict(doc) for doc_id, doc in self.documents.items() }, f, indent=2, ensure_ascii=False) def _generate_doc_id(self, title: str, doc_type: str) -> str: """Generate unique document ID.""" # Extract first letters of words, append type code words = re.findall(r'\b\w', title.upper()) prefix = ''.join(words[:3]) if words else 'DOC' timestamp = datetime.now().strftime('%y%m%d%H%M') return f"{prefix}-{doc_type}-{timestamp}" def _parse_version(self, version: str) -> Tuple[int, int, int]: """Parse semantic version string.""" match = self.VERSION_PATTERN.match(version) if match: return tuple(int(x) for x in match.groups()) raise ValueError(f"Invalid version format: {version}") def _increment_version(self, current: str, change_type: str) -> str: """Increment version based on change type.""" major, minor, edit = self._parse_version(current) if change_type == "Major": return f"{major+1}.0.0" elif change_type == "Minor": return f"{major}.{minor+1}.0" else: # Edit return f"{major}.{minor}.{edit+1}" def _calculate_checksum(self, filepath: Path) -> str: """Calculate SHA256 checksum of document file.""" with open(filepath, 'rb') as f: return hashlib.sha256(f.read()).hexdigest() def create_document( self, title: str, content: str, author: str, doc_type: str, change_summary: str = "Initial release", attachments: List[str] = None ) -> DocumentVersion: """Create a new document version.""" if doc_type not in self.DOCUMENT_TYPES: raise ValueError(f"Invalid document type. Choose from: {list(self.DOCUMENT_TYPES.keys())}") doc_id = self._generate_doc_id(title, doc_type) version = "1.0.0" revision_date = datetime.now().strftime('%Y-%m-%d') next_review = (datetime.now() + timedelta(days=365)).strftime('%Y-%m-%d') # Save document content doc_path = self.doc_store / f"{doc_id}_v{version}.md" with open(doc_path, 'w', encoding='utf-8') as f: f.write(content) doc = DocumentVersion( doc_id=doc_id, title=title, version=version, revision_date=revision_date, author=author, status="Approved", # Initially approved for simplicity change_summary=change_summary, next_review_date=next_review, attachments=attachments or [], checksum=self._calculate_checksum(doc_path) ) self.documents[doc_id] = doc self._save_metadata() return doc def revise_document( self, doc_id: str, new_content: str, change_author: str, change_type: str = "Edit", change_summary: str = "", attachments: List[str] = None ) -> Optional[DocumentVersion]: """Create a new revision of an existing document.""" if doc_id not in self.documents: return None old_doc = self.documents[doc_id] new_version = self._increment_version(old_doc.version, change_type) revision_date = datetime.now().strftime('%Y-%m-%d') # Archive old version old_path = self.doc_store / f"{doc_id}_v{old_doc.version}.md" archive_path = self.doc_store / "archive" / f"{doc_id}_v{old_doc.version}_{revision_date}.md" archive_path.parent.mkdir(exist_ok=True) if old_path.exists(): os.rename(old_path, archive_path) # Save new content doc_path = self.doc_store / f"{doc_id}_v{new_version}.md" with open(doc_path, 'w', encoding='utf-8') as f: f.write(new_content) # Create new document record new_doc = DocumentVersion( doc_id=doc_id, title=old_doc.title, version=new_version, revision_date=revision_date, author=change_author, status="Draft", # Needs re-approval change_summary=change_summary or f"Revision {new_version}", next_review_date=(datetime.now() + timedelta(days=365)).strftime('%Y-%m-%d'), attachments=attachments or old_doc.attachments, checksum=self._calculate_checksum(doc_path) ) self.documents[doc_id] = new_doc self._save_metadata() return new_doc def approve_document( self, doc_id: str, approver_name: str, approver_title: str, comments: str = "" ) -> bool: """Approve a document with electronic signature.""" if doc_id not in self.documents: return False doc = self.documents[doc_id] if doc.status != "Draft": return False signature = { "name": approver_name, "title": approver_title, "date": datetime.now().strftime('%Y-%m-%d %H:%M'), "comments": comments, "signature_hash": hashlib.sha256(f"{doc_id}{doc.version}{approver_name}".encode()).hexdigest()[:16] } doc.approved_by.append(approver_name) doc.signed_by.append(signature) # Approve if enough approvers (simplified: 1 is enough for demo) doc.status = "Approved" self._save_metadata() return True def withdraw_document(self, doc_id: str, reason: str, withdrawn_by: str) -> bool: """Withdraw/obsolete a document.""" if doc_id not in self.documents: return False doc = self.documents[doc_id] doc.status = "Obsolete" doc.change_summary = f"OBsolete: {reason}" # Add withdrawal signature signature = { "name": withdrawn_by, "title": "QMS Manager", "date": datetime.now().strftime('%Y-%m-%d %H:%M'), "comments": reason, "signature_hash": hashlib.sha256(f"{doc_id}OB{withdrawn_by}".encode()).hexdigest()[:16] } doc.signed_by.append(signature) self._save_metadata() return True def get_document_history(self, doc_id: str) -> List[Dict]: """Get version history for a document.""" history = [] pattern = f"{doc_id}_v*.md" for file in self.doc_store.glob(pattern): match = re.search(r'_v(\d+\.\d+\.\d+)\.md$', file.name) if match: version = match.group(1) stat = file.stat() history.append({ "version": version, "filename": file.name, "size": stat.st_size, "modified": datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M') }) # Check archive for file in (self.doc_store / "archive").glob(f"{doc_id}_v*.md"): match = re.search(r'_v(\d+\.\d+\.\d+)_(\d{4}-\d{2}-\d{2})\.md$', file.name) if match: version, date = match.groups() history.append({ "version": version, "filename": file.name, "status": "archived", "archived_date": date }) return sorted(history, key=lambda x: x["version"]) def generate_document_matrix(self) -> Dict: """Generate document matrix report.""" matrix = { "total_documents": len(self.documents), "by_status": {}, "by_type": {}, "documents": [] } for doc in self.documents.values(): # By status matrix["by_status"][doc.status] = matrix["by_status"].get(doc.status, 0) + 1 # By type (from doc_id) doc_type = doc.doc_id.split('-')[1] if '-' in doc.doc_id else "Unknown" matrix["by_type"][doc_type] = matrix["by_type"].get(doc_type, 0) + 1 matrix["documents"].append({ "doc_id": doc.doc_id, "title": doc.title, "type": doc_type, "version": doc.version, "status": doc.status, "author": doc.author, "last_modified": doc.revision_date, "next_review": doc.next_review_date, "approved_by": doc.approved_by }) matrix["documents"].sort(key=lambda x: (x["type"], x["title"])) return matrix def format_matrix_text(matrix: Dict) -> str: """Format document matrix as text.""" lines = [ "=" * 80, "QUALITY DOCUMENTATION MATRIX", "=" * 80, f"Total Documents: {matrix['total_documents']}", "", "BY STATUS", "-" * 40, ] for status, count in matrix["by_status"].items(): lines.append(f" {status}: {count}") lines.extend([ "", "BY TYPE", "-" * 40, ]) for dtype, count in matrix["by_type"].items(): lines.append(f" {dtype}: {count}") lines.extend([ "", "DOCUMENT LIST", "-" * 40, f"{'ID':<20} {'Type':<8} {'Version':<10} {'Status':<12} {'Title':<30}", "-" * 80, ]) for doc in matrix["documents"]: lines.append(f"{doc['doc_id'][:19]:<20} {doc['type']:<8} {doc['version']:<10} {doc['status']:<12} {doc['title'][:29]:<30}") lines.append("=" * 80) return "\n".join(lines) def main(): parser = argparse.ArgumentParser(description="Document Version Control for Quality Documentation") parser.add_argument("--create", type=str, help="Create new document from template") parser.add_argument("--title", type=str, help="Document title (required with --create)") parser.add_argument("--type", choices=list(DocumentVersionControl.DOCUMENT_TYPES.keys()), help="Document type") parser.add_argument("--author", type=str, default="QMS Manager", help="Document author") parser.add_argument("--revise", type=str, help="Revise existing document (doc_id)") parser.add_argument("--reason", type=str, help="Reason for revision") parser.add_argument("--approve", type=str, help="Approve document (doc_id)") parser.add_argument("--approver", type=str, help="Approver name") parser.add_argument("--withdraw", type=str, help="Withdraw document (doc_id)") parser.add_argument("--reason", type=str, help="Withdrawal reason") parser.add_argument("--status", action="store_true", help="Show document status") parser.add_argument("--matrix", action="store_true", help="Generate document matrix") parser.add_argument("--output", choices=["text", "json"], default="text") parser.add_argument("--interactive", action="store_true", help="Interactive mode") args = parser.parse_args() dvc = DocumentVersionControl() if args.create and args.title and args.type: # Create new document with default content template = f"""# {args.title} **Document ID:** [auto-generated] **Version:** 1.0.0 **Date:** {datetime.now().strftime('%Y-%m-%d')} **Author:** {args.author} ## Purpose [Describe the purpose and scope of this document] ## Responsibility [List roles and responsibilities] ## Procedure [Detailed procedure steps] ## References [List referenced documents] ## Revision History | Version | Date | Author | Change Summary | |---------|------|--------|----------------| | 1.0.0 | {datetime.now().strftime('%Y-%m-%d')} | {args.author} | Initial release | """ doc = dvc.create_document( title=args.title, content=template, author=args.author, doc_type=args.type, change_summary=args.reason or "Initial release" ) print(f"āœ… Created document {doc.doc_id} v{doc.version}") print(f" File: doc_store/{doc.doc_id}_v{doc.version}.md") elif args.revise and args.reason: # Add revision reason to the content (would normally modify the file) print(f"šŸ“ Would revise document {args.revise} - reason: {args.reason}") print(" Note: In production, this would load existing content, make changes, and create new revision") elif args.approve and args.approver: success = dvc.approve_document(args.approve, args.approver, "QMS Manager") print(f"{'āœ… Approved' if success else 'āŒ Failed'} document {args.approve}") elif args.withdraw and args.reason: success = dvc.withdraw_document(args.withdraw, args.reason, "QMS Manager") print(f"{'āœ… Withdrawn' if success else 'āŒ Failed'} document {args.withdraw}") elif args.matrix: matrix = dvc.generate_document_matrix() if args.output == "json": print(json.dumps(matrix, indent=2)) else: print(format_matrix_text(matrix)) elif args.status: print("šŸ“‹ Document Status:") for doc_id, doc in dvc.documents.items(): print(f" {doc_id} v{doc.version} - {doc.title} [{doc.status}]") else: # Demo print("šŸ“ Document Version Control System Demo") print(" Repository contains", len(dvc.documents), "documents") if dvc.documents: print("\n Existing documents:") for doc in dvc.documents.values(): print(f" {doc.doc_id} v{doc.version} - {doc.title} ({doc.status})") print("\nšŸ’” Usage:") print(" --create \"SOP-001\" --title \"Document Title\" --type SOP --author \"Your Name\"") print(" --revise DOC-001 --reason \"Regulatory update\"") print(" --approve DOC-001 --approver \"Approver Name\"") print(" --matrix --output text/json") if __name__ == "__main__": main()