TimeGate
Docházkový systém využívající QR kód.
← 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