JKJan Kaminský/ code
RepositoriesAdmin

TimeGate

Docházkový systém využívající QR kód.

Python
‹› Code▣ Screenshots 1Updated před 1 h
← Back14.5 KB
timegate.py156 lines · 14.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
import sys,sqlite3,secrets,hashlib,socket,threading,html,csv,shutil
from pathlib import Path
from datetime import datetime
from http.server import ThreadingHTTPServer,BaseHTTPRequestHandler
from urllib.parse import parse_qs,urlparse
from http.cookies import SimpleCookie
from PySide6.QtCore import Qt,QTimer
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import *
import qrcode

BASE=Path(__file__).resolve().parent; DATA=BASE/"data"; DB=DATA/"timegate.db"; SESSIONS={}
def con():
 c=sqlite3.connect(DB,check_same_thread=False);c.row_factory=sqlite3.Row;return c
def now():return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def hp(p,s=None):
 s=s or secrets.token_hex(16);return s+"$"+hashlib.pbkdf2_hmac("sha256",p.encode(),s.encode(),180000).hex()
def vp(p,h):
 try:s,d=h.split("$",1);return secrets.compare_digest(hp(p,s).split("$",1)[1],d)
 except:return False
def init():
 DATA.mkdir(exist_ok=True)
 with con() as c:
  c.executescript("""CREATE TABLE IF NOT EXISTS employees(id INTEGER PRIMARY KEY,name TEXT,email TEXT UNIQUE,password TEXT,role TEXT DEFAULT 'employee',active INTEGER DEFAULT 1);
CREATE TABLE IF NOT EXISTS locations(id INTEGER PRIMARY KEY,building TEXT,name TEXT,token TEXT UNIQUE,active INTEGER DEFAULT 1);
CREATE TABLE IF NOT EXISTS attendance(id INTEGER PRIMARY KEY,employee_id INTEGER,location_id INTEGER,action TEXT,created_at TEXT);""")
  if not c.execute("select 1 from employees").fetchone():c.execute("insert into employees(name,email,password,role) values(?,?,?,?)",("Administrátor","admin@timegate.local",hp("ChangeMe123!"),"admin"))
  if not c.execute("select 1 from locations").fetchone():
   c.execute("insert into locations(building,name,token) values(?,?,?)",("Budova 1","Hala 11",secrets.token_urlsafe(24)))
def ip():
 try:
  s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);s.connect(("8.8.8.8",80));x=s.getsockname()[0];s.close();return x
 except:return "127.0.0.1"
CSS="""*{box-sizing:border-box}body{margin:0;background:#0d1117;color:#e6edf3;font-family:system-ui}.w{max-width:460px;margin:auto;padding:30px 18px}.brand{font-weight:900;letter-spacing:.12em;margin-bottom:40px}.card{background:#161b22;border:1px solid #30363d;border-radius:14px;padding:24px}h1{margin:5px 0}p,small{color:#8b949e}input,button{width:100%;height:50px;border-radius:8px;margin-top:10px}input{background:#0d1117;color:white;border:1px solid #30363d;padding:10px}button{border:0;background:#238636;color:white;font-weight:800}.out{background:#30363d}.msg{padding:12px;border:1px solid #238636;border-radius:7px;margin-bottom:12px}.place{padding:12px;border:1px solid #30363d;border-radius:8px;margin:18px 0}.place span{display:block;color:#8b949e;font-size:12px}a{color:#58a6ff}"""
class H(BaseHTTPRequestHandler):
 def log_message(self,*a):pass
 def user(self):
  c=SimpleCookie(self.headers.get("Cookie",""));x=c.get("tg");return SESSIONS.get(x.value) if x else None
 def sendh(self,b,cookie=None):
  raw=f"<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'><style>{CSS}</style><div class=w><div class=brand>◆ TIMEGATE</div>{b}</div>".encode()
  self.send_response(200);self.send_header("Content-Type","text/html; charset=utf-8");self.send_header("Content-Length",str(len(raw)))
  if cookie:self.send_header("Set-Cookie",cookie)
  self.end_headers();self.wfile.write(raw)
 def do_GET(self):
  p=urlparse(self.path)
  if p.path=="/logout":
   self.send_response(302);self.send_header("Location","/");self.send_header("Set-Cookie","tg=; Max-Age=0; Path=/");self.end_headers();return
  if p.path.startswith("/scan/"):return self.scan(p.path.split("/scan/",1)[1])
  self.sendh("<div class=card><h1>QR docházka</h1><p>Naskenujte QR kód pracoviště.</p></div>")
 def do_POST(self):
  p=urlparse(self.path)
  if not p.path.startswith("/scan/"):return self.send_error(404)
  tok=p.path.split("/scan/",1)[1];n=int(self.headers.get("Content-Length",0));f=parse_qs(self.rfile.read(n).decode());u=self.user()
  if not u:
   with con() as c:x=c.execute("select * from employees where email=? and active=1",(f.get("email",[""])[0].lower(),)).fetchone()
   if not x or not vp(f.get("password",[""])[0],x["password"]):return self.scan(tok,"Neplatné přihlášení")
   sid=secrets.token_urlsafe(24);SESSIONS[sid]=dict(x);self.send_response(302);self.send_header("Location","/scan/"+tok);self.send_header("Set-Cookie",f"tg={sid}; Path=/; HttpOnly; SameSite=Lax");self.end_headers();return
  act=f.get("action",[""])[0]
  if act not in ("in","out"):return self.send_error(400)
  with con() as c:
   loc=c.execute("select * from locations where token=? and active=1",(tok,)).fetchone()
   last=c.execute("select action from attendance where employee_id=? order by id desc limit 1",(u["id"],)).fetchone()
   if last and last["action"]==act:return self.scan(tok,"Poslední záznam je už stejný.")
   c.execute("insert into attendance(employee_id,location_id,action,created_at) values(?,?,?,?)",(u["id"],loc["id"],act,now()))
  self.scan(tok,"Příchod zaznamenán." if act=="in" else "Odchod zaznamenán.")
 def scan(self,tok,msg=""):
  with con() as c:l=c.execute("select * from locations where token=? and active=1",(tok,)).fetchone()
  if not l:return self.send_error(404)
  u=self.user();m=f"<div class=msg>{html.escape(msg)}</div>" if msg else ""
  place=f"<div class=place><b>{html.escape(l['name'])}</b><span>{html.escape(l['building'])}</span></div>"
  if not u:b=m+f"<div class=card><h1>{html.escape(l['name'])}</h1><p>{html.escape(l['building'])}</p>{place}<form method=post><input name=email type=email placeholder='Firemní e-mail' required><input name=password type=password placeholder='Heslo' required><button>Přihlásit se</button></form></div>"
  else:b=m+f"<div class=card><h1>{html.escape(l['name'])}</h1><p>{html.escape(l['building'])}</p><p>Přihlášen: <b>{html.escape(u['name'])}</b></p><form method=post><button name=action value=in>PŘÍCHOD</button><button class=out name=action value=out>ODCHOD</button></form><p><a href=/logout>Odhlásit účet</a></p></div>"
  self.sendh(b)
class Server:
 def __init__(self):self.s=None
 def start(self):self.s=ThreadingHTTPServer(("0.0.0.0",8765),H);threading.Thread(target=self.s.serve_forever,daemon=True).start()
 def stop(self):
  if self.s:self.s.shutdown()

STYLE="""QWidget{background:#0d1117;color:#e6edf3;font:13px 'Segoe UI'}QFrame#side{background:#010409;border-right:1px solid #30363d}QFrame#card{background:#161b22;border:1px solid #30363d;border-radius:9px}QPushButton{background:#21262d;border:1px solid #30363d;border-radius:6px;padding:9px}QPushButton:hover{background:#30363d}QPushButton#nav{border:0;background:transparent;text-align:left}QPushButton#primary{background:#238636;font-weight:bold}QLineEdit,QComboBox{background:#010409;border:1px solid #30363d;border-radius:6px;padding:8px}QTableWidget{border:1px solid #30363d;gridline-color:#21262d}QHeaderView::section{background:#161b22;color:#8b949e;border:0;padding:8px}"""
class Main(QMainWindow):
 def __init__(self):
  super().__init__();self.setWindowTitle("TimeGate Desktop");self.resize(1200,760);self.s=Server()
  try:self.s.start();self.online=True
  except Exception as e:self.online=False
  self.ui();self.refresh();self.t=QTimer(self);self.t.timeout.connect(self.refresh);self.t.start(5000)
 def page(self,title,sub):
  p=QWidget();v=QVBoxLayout(p);v.setContentsMargins(28,25,28,25);h=QLabel(title);h.setStyleSheet("font-size:26px;font-weight:800");v.addWidget(h);m=QLabel(sub);m.setStyleSheet("color:#8b949e");v.addWidget(m);v.addSpacing(15);return p,v
 def ui(self):
  w=QWidget();self.setCentralWidget(w);r=QHBoxLayout(w);r.setContentsMargins(0,0,0,0);r.setSpacing(0);side=QFrame();side.setObjectName("side");side.setFixedWidth(210);sl=QVBoxLayout(side);sl.setContentsMargins(18,22,18,18);b=QLabel("◆  TIMEGATE");b.setStyleSheet("font-size:18px;font-weight:800");sl.addWidget(b);sl.addSpacing(25);self.stack=QStackedWidget()
  for name,i in [("Přehled",0),("Zaměstnanci",1),("Budovy a QR",2),("Docházka",3),("Nastavení",4)]:
   x=QPushButton(name);x.setObjectName("nav");x.clicked.connect(lambda _,n=i:self.stack.setCurrentIndex(n));sl.addWidget(x)
  sl.addStretch();sl.addWidget(QLabel("TimeGate Desktop v2.0"));r.addWidget(side);r.addWidget(self.stack,1);self.dashboard();self.employees();self.locations();self.attendance();self.settings()
 def table(self,heads):
  t=QTableWidget(0,len(heads));t.setHorizontalHeaderLabels(heads);t.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch);return t
 def dashboard(self):
  p,v=self.page("Přehled","Živý stav docházky.");self.status=QLabel();v.addWidget(self.status);self.dt=self.table(["Zaměstnanec","Budova","Pracoviště","Příchod"]);v.addWidget(self.dt);self.stack.addWidget(p)
 def employees(self):
  p,v=self.page("Zaměstnanci","Účty pro QR docházku.");f=QHBoxLayout();self.en=QLineEdit();self.en.setPlaceholderText("Jméno");self.ee=QLineEdit();self.ee.setPlaceholderText("E-mail");self.ep=QLineEdit();self.ep.setPlaceholderText("Heslo");add=QPushButton("Přidat");add.setObjectName("primary");add.clicked.connect(self.adde)
  for x in [self.en,self.ee,self.ep,add]:f.addWidget(x)
  v.addLayout(f);self.et=self.table(["Jméno","E-mail","Role","Aktivní"]);v.addWidget(self.et);self.stack.addWidget(p)
 def locations(self):
  p,v=self.page("Budovy a QR","Např. Budova 1 → Hala 11.");f=QHBoxLayout();self.lb=QLineEdit();self.lb.setPlaceholderText("Budova 1");self.ln=QLineEdit();self.ln.setPlaceholderText("Hala 11");add=QPushButton("Přidat");add.setObjectName("primary");add.clicked.connect(self.addl)
  for x in [self.lb,self.ln,add]:f.addWidget(x)
  v.addLayout(f);self.lt=self.table(["Budova","Pracoviště","QR URL","QR"]);v.addWidget(self.lt);self.stack.addWidget(p)
 def attendance(self):
  p,v=self.page("Docházka","Historie příchodů a odchodů.");ex=QPushButton("Export CSV");ex.clicked.connect(self.export);v.addWidget(ex);self.at=self.table(["Čas","Zaměstnanec","Akce","Budova","Pracoviště"]);v.addWidget(self.at);self.stack.addWidget(p)
 def settings(self):
  p,v=self.page("Nastavení","QR server běží automaticky.");self.url=QLineEdit();self.url.setReadOnly(True);v.addWidget(QLabel("Adresa serveru pro telefony:"));v.addWidget(self.url);m=QLabel("PC a telefon musí být ve stejné Wi‑Fi/LAN. Při prvním spuštění povol TimeGate ve Windows Firewallu.");m.setWordWrap(True);v.addWidget(m);bk=QPushButton("Vytvořit zálohu databáze");bk.clicked.connect(self.backup);v.addWidget(bk);v.addStretch();self.stack.addWidget(p)
 def refresh(self):
  base=f"http://{ip()}:8765";self.url.setText(base);self.status.setText(("● QR SERVER ONLINE  " if self.online else "● QR SERVER CHYBA  ")+base)
  with con() as c:
   es=c.execute("select * from employees order by name").fetchall();ls=c.execute("select * from locations where active=1 order by building,name").fetchall();ats=c.execute("""select a.*,e.name employee,l.building,l.name location from attendance a join employees e on e.id=a.employee_id join locations l on l.id=a.location_id order by a.id desc limit 1000""").fetchall();prs=c.execute("""select e.name,a.created_at,l.building,l.name location from employees e join attendance a on a.id=(select id from attendance x where x.employee_id=e.id order by id desc limit 1) join locations l on l.id=a.location_id where a.action='in'""").fetchall()
  self.et.setRowCount(len(es))
  for r,x in enumerate(es):
   for j,z in enumerate([x["name"],x["email"],x["role"],"Ano" if x["active"] else "Ne"]):self.et.setItem(r,j,QTableWidgetItem(str(z)))
  self.lt.setRowCount(len(ls))
  for r,x in enumerate(ls):
   u=f"{base}/scan/{x['token']}"
   for j,z in enumerate([x["building"],x["name"],u]):self.lt.setItem(r,j,QTableWidgetItem(z))
   b=QPushButton("Zobrazit QR");b.clicked.connect(lambda _,y=dict(x):self.qr(y));self.lt.setCellWidget(r,3,b)
  self.at.setRowCount(len(ats))
  for r,x in enumerate(ats):
   for j,z in enumerate([x["created_at"],x["employee"],"Příchod" if x["action"]=="in" else "Odchod",x["building"],x["location"]]):self.at.setItem(r,j,QTableWidgetItem(str(z)))
  self.dt.setRowCount(len(prs))
  for r,x in enumerate(prs):
   for j,z in enumerate([x["name"],x["building"],x["location"],x["created_at"][-8:]]):self.dt.setItem(r,j,QTableWidgetItem(str(z)))
 def adde(self):
  try:
   if not self.en.text() or not self.ee.text() or not self.ep.text():raise ValueError("Vyplň všechna pole.")
   with con() as c:c.execute("insert into employees(name,email,password) values(?,?,?)",(self.en.text().strip(),self.ee.text().strip().lower(),hp(self.ep.text())))
   self.en.clear();self.ee.clear();self.ep.clear();self.refresh()
  except Exception as e:QMessageBox.warning(self,"TimeGate",str(e))
 def addl(self):
  if not self.lb.text() or not self.ln.text():return QMessageBox.warning(self,"TimeGate","Vyplň budovu i pracoviště.")
  with con() as c:c.execute("insert into locations(building,name,token) values(?,?,?)",(self.lb.text().strip(),self.ln.text().strip(),secrets.token_urlsafe(24)))
  self.ln.clear();self.refresh()
 def qr(self,l):
  u=f"http://{ip()}:8765/scan/{l['token']}";out=DATA/f"QR_{l['id']}.png";qrcode.make(u).save(out);d=QDialog(self);d.setWindowTitle(l["name"]);v=QVBoxLayout(d);h=QLabel(f"{l['building']} / {l['name']}");h.setAlignment(Qt.AlignCenter);v.addWidget(h);im=QLabel();im.setPixmap(QPixmap(str(out)).scaled(360,360,Qt.KeepAspectRatio));im.setAlignment(Qt.AlignCenter);v.addWidget(im);q=QLineEdit(u);q.setReadOnly(True);v.addWidget(q);sv=QPushButton("Uložit QR jako PNG");sv.clicked.connect(lambda:self.saveqr(out,l));v.addWidget(sv);d.exec()
 def saveqr(self,out,l):
  f,_=QFileDialog.getSaveFileName(self,"Uložit QR",f"{l['building']}-{l['name']}.png","PNG (*.png)")
  if f:shutil.copy2(out,f)
 def export(self):
  f,_=QFileDialog.getSaveFileName(self,"Export","dochazka.csv","CSV (*.csv)")
  if not f:return
  with con() as c:rows=c.execute("""select a.created_at,e.name,e.email,a.action,l.building,l.name location from attendance a join employees e on e.id=a.employee_id join locations l on l.id=a.location_id order by a.id""").fetchall()
  with open(f,"w",newline="",encoding="utf-8-sig") as o:
   w=csv.writer(o,delimiter=";");w.writerow(["Čas","Jméno","E-mail","Akce","Budova","Pracoviště"])
   for x in rows:w.writerow([x["created_at"],x["name"],x["email"],"Příchod" if x["action"]=="in" else "Odchod",x["building"],x["location"]])
 def backup(self):
  (BASE/"backups").mkdir(exist_ok=True);f=BASE/"backups"/f"timegate-{datetime.now():%Y%m%d-%H%M%S}.db";shutil.copy2(DB,f);QMessageBox.information(self,"TimeGate",f"Záloha vytvořena:\n{f}")
 def closeEvent(self,e):self.s.stop();e.accept()
def main():
 init();a=QApplication(sys.argv);a.setStyleSheet(STYLE);w=Main();w.show();sys.exit(a.exec())
if __name__=="__main__":main()

Screenshots

Ukázky aplikace

1