Initial commit: Queensway Europe Telco Portal (Flask + SQLite, Docker deploy)

This commit is contained in:
Martin Belan
2026-09-11 07:23:40 +00:00
commit 919df2220e
42 changed files with 2206 additions and 0 deletions
Executable
+17
View File
@@ -0,0 +1,17 @@
# Live application data - real phone numbers, employee names, login credentials, session key.
# Never commit these. The Docker volume recreates this folder at deploy time.
/TelcoPortal-data/
# Leftovers not meant to be tracked (see chat history for context).
/_to_delete/
# One-off bulk-import source data containing real employee names and phone numbers.
TelcoPortal-source/owner_mapping.csv
# Python
__pycache__/
*.pyc
# OS
.DS_Store
Thumbs.db
+65
View File
@@ -0,0 +1,65 @@
# Queensway Europe Telco Portal - Docker (runs locally on this PC)
A Docker container you run directly on your own PC, same setup as TostaSklad. Requires
**Docker Desktop for Windows** (with WSL2 backend, the default) - download from
https://www.docker.com/products/docker-desktop/ if you don't have it yet.
## First run
Open a terminal (PowerShell) in this `QWE Telco` folder and run:
```
docker compose up -d --build
```
This builds the image from `TelcoPortal-source/` (Python/Flask app) and starts the container in
the background.
Open **http://localhost:8092/** in your browser.
The Mobile Phones table is pre-loaded from the one-time Excel import (see
`TelcoPortal-source/import_excel.py` and the main README). There is no login - anyone who can
reach the URL can view and edit.
## Data persistence
The `TelcoPortal-data/` folder next to this file is bind-mounted into the container so the
database survives container restarts, rebuilds, and updates:
- `TelcoPortal-data/telco.db` - the SQLite database (both Mobile Phones and Internet
Connectivity tables live in this one file)
**Back up by simply copying `TelcoPortal-data/telco.db`.** Nothing important lives inside the
container itself.
## Common commands
```
docker compose logs -f # follow app logs
docker compose ps # check container status
docker compose down # stop and remove the container (data folder is untouched)
docker compose up -d # start it again
```
## Updating after you change the source code
```
docker compose up -d --build
```
This rebuilds the image with your latest changes from `TelcoPortal-source/` and replaces the
running container. Your data in `TelcoPortal-data/` is not affected.
## Changing the port
Edit the `ports:` line in `docker-compose.yml`, e.g. `"80:8000"` to use the standard HTTP port
instead of 8092, then run `docker compose up -d` again.
## Note on this setup
This was written and validated by running the Flask app directly (not inside Docker - Docker
itself was not available where these files were generated), so the app logic, database, and
import script are confirmed working end-to-end. The Docker build/run itself could not be
executed from where these files were generated. Please run the "First run" command above and
let me know if anything doesn't come up cleanly (e.g. build errors, port conflicts) so it can be
fixed.
+80
View File
@@ -0,0 +1,80 @@
# Queensway Europe Telco Portal
Management web portal for:
- The mobile phone repository (phone numbers, plan, contract expiration, replacement
eligibility, current owner, venue)
- Internet connectivity at each restaurant venue
Same deployment approach as TostaSklad/TostaPortal: a Docker container you run locally on this
PC with Docker Desktop, with all data kept in a bind-mounted folder outside the container. See
`README-Docker.md` for how to start/stop it.
## Folder layout
```
QWE Telco/
docker-compose.yml
README-Docker.md <- Docker instructions (start here)
TelcoPortal-README.md <- this file
TelcoPortal-source/ <- Flask app source (rebuilt into the Docker image)
TelcoPortal-data/
telco.db <- the SQLite database (both tables live in this one file)
```
## Data model
**Mobile Phones** (`phones` table): Phone Number, Plan, Contract Expiration, Eligibility for
Replacement, Current Owner, Venue.
**Internet Connectivity** (`internet_connections` table): Venue, ISP Provider, Plan, Contract
Expiration, Notes. This table starts empty - the source Excel file only had mobile phone data -
add rows with the "+ Add Connection" button on the Internet Connectivity page as you go.
There is no login. Anyone who can reach the URL (`http://localhost:8092/` by default) can view
and edit both tables.
## How the mobile phone data was loaded
The one-off migration script `TelcoPortal-source/import_excel.py` reads the source Excel file
(`Mobilne telefony 20260901.xlsx`) and loads it into `TelcoPortal-data/telco.db`. This has
**already been run once** to populate the database - the Excel file is not needed by the running
app and can be archived/deleted.
Column mapping from the Excel file:
| Excel column (Slovak) | Portal field |
|-----------------------------|------------------------------|
| T.Č. | Phone Number (re-prefixed with the leading 0 Excel drops from numeric cells) |
| Užívateľský balík | Plan |
| Viazanosť do: | Contract Expiration |
| Možnost výmeny od: | Eligibility for Replacement (blank/`---` in the source means "eligible now", not "unknown") |
| Meno užívatela | **not auto-split** - see note below |
### Current Owner / Venue
The source `Meno užívatela` column mixes two things: a handful of rows have a
`"<number> - <person or venue>"` pattern, but most rows just repeat the company name
("Queensway Restaurants Slovakia, s.r.o.") with nothing usable as a venue. There was no reliable
way to auto-split this into separate Owner and Venue fields, so on import:
- **Current Owner** was set to the raw `Meno užívatela` text for each row (nothing was thrown
away)
- **Venue** was left blank
To fix this up in bulk instead of editing 70+ rows by hand one at a time, re-run the import with
a mapping file:
```
python import_excel.py "Mobilne telefony 20260901.xlsx" --mapping owner_venue_mapping.csv
```
where `owner_venue_mapping.csv` has three columns: `phone_number,current_owner,venue` (phone
number in the same format shown in the portal, e.g. `0905988240`). Rows are matched by phone
number, and only the rows present in the mapping file are updated - existing Plan/Contract
Expiration/Eligibility data is left as-is either way. You can also just fix individual rows with
the Edit button in the portal itself.
## Backups
Copy or zip `TelcoPortal-data/telco.db` any time - that single file is the entire database.
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
.venv/
venv/
data/
*.db
.git/
+20
View File
@@ -0,0 +1,20 @@
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS runtime
WORKDIR /app
RUN pip install --no-cache-dir --upgrade pip
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# The SQLite database lives outside the container - see docker-compose.yml volumes.
VOLUME ["/app/data"]
ENV TELCO_DATA_DIR=/app/data
ENV PORT=8000
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "app:app"]
+443
View File
@@ -0,0 +1,443 @@
import os
import secrets
from datetime import date, datetime, timedelta
from functools import wraps
from flask import Flask, jsonify, redirect, render_template, request, session, url_for
from sqlalchemy import text
from werkzeug.security import check_password_hash, generate_password_hash
from models import InternetConnection, Phone, User, db
DATA_DIR = os.environ.get("TELCO_DATA_DIR", os.path.join(os.path.dirname(__file__), "..", "data"))
os.makedirs(DATA_DIR, exist_ok=True)
DB_PATH = os.path.join(DATA_DIR, "telco.db")
SECRET_KEY_PATH = os.path.join(DATA_DIR, "secret_key.txt")
# Seeded once, only if the users table is empty (first run against a fresh database).
# Passwords are hashed before they ever touch the database - see seed_users().
SEED_USERS = [
("MBelan", "KFC@99", "admin"),
("AMaar", "KFC@99", "admin"),
("CGasser", "KFC@99", "admin"),
("RSimko", "KFC@99", "admin"),
("TPanakova", "KFC@99", "admin"),
("JLapinova", "KFC@11", "viewer"),
("JTrnovska", "KFC@11", "viewer"),
]
def get_or_create_secret_key():
"""Persist a random session-signing key in the data dir so it survives container restarts
and is shared by every gunicorn worker (each worker imports this module independently, so
a key generated only in memory would differ per worker and invalidate sessions randomly)."""
try:
with open(SECRET_KEY_PATH, "r") as f:
existing = f.read().strip()
if existing:
return existing
except FileNotFoundError:
pass
key = secrets.token_hex(32)
tmp_path = f"{SECRET_KEY_PATH}.tmp.{os.getpid()}"
try:
with open(tmp_path, "w") as f:
f.write(key)
os.replace(tmp_path, SECRET_KEY_PATH)
except OSError:
pass
# Re-read in case another worker won the race and wrote its own key first.
try:
with open(SECRET_KEY_PATH, "r") as f:
existing = f.read().strip()
if existing:
return existing
except FileNotFoundError:
pass
return key
def create_app():
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{DB_PATH}"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["JSON_SORT_KEYS"] = False
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30)
app.secret_key = os.environ.get("TELCO_SECRET_KEY") or get_or_create_secret_key()
db.init_app(app)
with app.app_context():
db.create_all()
run_migrations()
seed_users()
register_routes(app)
return app
def run_migrations():
"""Lightweight in-place migrations for columns added after the DB was first created.
db.create_all() only creates missing tables, not missing columns on existing tables."""
existing_columns = {row[1] for row in db.session.execute(text("PRAGMA table_info(phones)")).fetchall()}
if "comment" not in existing_columns:
db.session.execute(text("ALTER TABLE phones ADD COLUMN comment TEXT"))
db.session.commit()
if "verified" not in existing_columns:
db.session.execute(text("ALTER TABLE phones ADD COLUMN verified VARCHAR(8)"))
db.session.commit()
def seed_users():
"""First-run only: create the initial login accounts if no users exist yet at all."""
if User.query.count() > 0:
return
for username, password, role in SEED_USERS:
db.session.add(User(username=username, password_hash=generate_password_hash(password), role=role))
db.session.commit()
def parse_date(value):
"""Parse an ISO date string ('YYYY-MM-DD') from a form/JSON payload. Empty -> None."""
if not value:
return None
if isinstance(value, date):
return value
value = value.strip()
if not value:
return None
return datetime.strptime(value, "%Y-%m-%d").date()
VENUE_OPTIONS = ["QWE Headoffice", "KFC Restaurant"]
VERIFIED_OPTIONS = ["Yes", "No"]
ROLE_OPTIONS = ["admin", "viewer"]
def login_required(view):
@wraps(view)
def wrapped(*args, **kwargs):
if "user_id" not in session:
if request.path.startswith("/api/"):
return jsonify({"error": "Login required."}), 401
return redirect(url_for("login", next=request.path))
return view(*args, **kwargs)
return wrapped
def admin_required(view):
@wraps(view)
def wrapped(*args, **kwargs):
if "user_id" not in session:
if request.path.startswith("/api/"):
return jsonify({"error": "Login required."}), 401
return redirect(url_for("login", next=request.path))
if session.get("role") != "admin":
if request.path.startswith("/api/"):
return jsonify({"error": "Admin access required."}), 403
return render_template("403.html"), 403
return view(*args, **kwargs)
return wrapped
def register_routes(app):
# ---------------------------------------------------------------
# Auth
# ---------------------------------------------------------------
@app.get("/login")
def login():
if "user_id" in session:
return redirect(url_for("index"))
return render_template("login.html", error=None, next=request.args.get("next", ""))
@app.post("/login")
def login_submit():
username = (request.form.get("username") or "").strip()
password = request.form.get("password") or ""
next_url = request.form.get("next") or ""
user = User.query.filter(db.func.lower(User.username) == username.lower()).first()
if not user or not check_password_hash(user.password_hash, password):
return render_template("login.html", error="Invalid username or password.", next=next_url), 401
session.clear()
session.permanent = True
session["user_id"] = user.id
session["username"] = user.username
session["role"] = user.role
if next_url.startswith("/") and not next_url.startswith("//"):
return redirect(next_url)
return redirect(url_for("index"))
@app.get("/logout")
def logout():
session.clear()
return redirect(url_for("login"))
@app.get("/")
@login_required
def index():
return render_template("index.html")
# ---------------------------------------------------------------
# Mobile phones
# ---------------------------------------------------------------
SORTABLE_PHONE_COLUMNS = {
"phone_number": Phone.phone_number,
"plan": Phone.plan,
"contract_expiration": Phone.contract_expiration,
"eligible_for_replacement": Phone.eligible_for_replacement,
"current_owner": Phone.current_owner,
"venue": Phone.venue,
"verified": Phone.verified,
"comment": Phone.comment,
}
@app.get("/phones")
@login_required
def phones_page():
sort = request.args.get("sort", "phone_number")
direction = request.args.get("dir", "asc")
if sort not in SORTABLE_PHONE_COLUMNS:
sort = "phone_number"
if direction not in ("asc", "desc"):
direction = "asc"
column = SORTABLE_PHONE_COLUMNS[sort]
order = column.desc() if direction == "desc" else column.asc()
# Secondary sort by phone number keeps ties in a stable, predictable order.
phones = Phone.query.order_by(order, Phone.phone_number.asc()).all()
today = date.today()
plan_options = [
row[0] for row in
db.session.query(Phone.plan).filter(Phone.plan.isnot(None)).distinct().order_by(Phone.plan).all()
]
headoffice_count = sum(1 for p in phones if p.venue == "QWE Headoffice")
restaurant_count = sum(1 for p in phones if p.venue == "KFC Restaurant")
unassigned_count = len(phones) - headoffice_count - restaurant_count
verified_count = sum(1 for p in phones if p.verified == "Yes")
not_verified_count = len(phones) - verified_count
stats = {
"total": len(phones),
"expired": sum(1 for p in phones if p.contract_expiration and p.contract_expiration < today),
"expiring_soon": sum(
1 for p in phones
if p.contract_expiration and p.contract_expiration >= today
and (p.contract_expiration - today).days <= 60
),
"eligible_now": sum(
1 for p in phones
if not p.eligible_for_replacement or p.eligible_for_replacement <= today
),
"headoffice": headoffice_count,
"restaurant": restaurant_count,
"unassigned": unassigned_count,
"headoffice_pct": round(100 * headoffice_count / len(phones)) if phones else 0,
"restaurant_pct": round(100 * restaurant_count / len(phones)) if phones else 0,
"verified": verified_count,
"not_verified": not_verified_count,
}
return render_template(
"phones.html",
phones=phones,
today=today,
sort=sort,
direction=direction,
plan_options=plan_options,
venue_options=VENUE_OPTIONS,
verified_options=VERIFIED_OPTIONS,
stats=stats,
active="phones",
)
@app.get("/api/phones/<int:phone_id>")
@login_required
def get_phone(phone_id):
phone = Phone.query.get_or_404(phone_id)
return jsonify(phone.to_dict())
@app.post("/api/phones")
@admin_required
def create_phone():
payload = request.get_json(force=True)
phone = Phone(
phone_number=(payload.get("phone_number") or "").strip(),
plan=(payload.get("plan") or "").strip() or None,
contract_expiration=parse_date(payload.get("contract_expiration")),
eligible_for_replacement=parse_date(payload.get("eligible_for_replacement")),
current_owner=(payload.get("current_owner") or "").strip() or None,
venue=(payload.get("venue") or "").strip() or None,
verified=(payload.get("verified") or "").strip() or None,
comment=(payload.get("comment") or "").strip() or None,
)
if not phone.phone_number:
return jsonify({"error": "Phone number is required."}), 400
db.session.add(phone)
db.session.commit()
return jsonify(phone.to_dict()), 201
@app.put("/api/phones/<int:phone_id>")
@admin_required
def update_phone(phone_id):
phone = Phone.query.get_or_404(phone_id)
payload = request.get_json(force=True)
# Phone Number and Plan are set once at creation and are not editable afterward -
# ignore any values sent for them here (enforced server-side, not just hidden in the UI).
phone.contract_expiration = parse_date(payload.get("contract_expiration"))
phone.eligible_for_replacement = parse_date(payload.get("eligible_for_replacement"))
phone.current_owner = (payload.get("current_owner") or "").strip() or None
phone.venue = (payload.get("venue") or "").strip() or None
phone.verified = (payload.get("verified") or "").strip() or None
phone.comment = (payload.get("comment") or "").strip() or None
db.session.commit()
return jsonify(phone.to_dict())
@app.delete("/api/phones/<int:phone_id>")
@admin_required
def delete_phone(phone_id):
phone = Phone.query.get_or_404(phone_id)
db.session.delete(phone)
db.session.commit()
return jsonify({"ok": True})
# ---------------------------------------------------------------
# Internet connectivity
# ---------------------------------------------------------------
@app.get("/internet")
@login_required
def internet_page():
connections = InternetConnection.query.order_by(InternetConnection.venue.asc()).all()
return render_template("internet.html", connections=connections, active="internet")
@app.get("/api/internet/<int:conn_id>")
@login_required
def get_connection(conn_id):
conn = InternetConnection.query.get_or_404(conn_id)
return jsonify(conn.to_dict())
@app.post("/api/internet")
@admin_required
def create_connection():
payload = request.get_json(force=True)
conn = InternetConnection(
venue=(payload.get("venue") or "").strip(),
isp_provider=(payload.get("isp_provider") or "").strip() or None,
plan=(payload.get("plan") or "").strip() or None,
contract_expiration=parse_date(payload.get("contract_expiration")),
notes=(payload.get("notes") or "").strip() or None,
)
if not conn.venue:
return jsonify({"error": "Venue is required."}), 400
db.session.add(conn)
db.session.commit()
return jsonify(conn.to_dict()), 201
@app.put("/api/internet/<int:conn_id>")
@admin_required
def update_connection(conn_id):
conn = InternetConnection.query.get_or_404(conn_id)
payload = request.get_json(force=True)
conn.venue = (payload.get("venue") or conn.venue).strip()
conn.isp_provider = (payload.get("isp_provider") or "").strip() or None
conn.plan = (payload.get("plan") or "").strip() or None
conn.contract_expiration = parse_date(payload.get("contract_expiration"))
conn.notes = (payload.get("notes") or "").strip() or None
db.session.commit()
return jsonify(conn.to_dict())
@app.delete("/api/internet/<int:conn_id>")
@admin_required
def delete_connection(conn_id):
conn = InternetConnection.query.get_or_404(conn_id)
db.session.delete(conn)
db.session.commit()
return jsonify({"ok": True})
# ---------------------------------------------------------------
# User accounts (admin only) - login/password management
# ---------------------------------------------------------------
@app.get("/users")
@admin_required
def users_page():
users = User.query.order_by(User.username.asc()).all()
admin_count = sum(1 for u in users if u.role == "admin")
viewer_count = len(users) - admin_count
return render_template(
"users.html",
users=users,
role_options=ROLE_OPTIONS,
admin_count=admin_count,
viewer_count=viewer_count,
active="users",
)
@app.post("/api/users")
@admin_required
def create_user():
payload = request.get_json(force=True)
username = (payload.get("username") or "").strip()
password = payload.get("password") or ""
role = (payload.get("role") or "").strip()
if not username or not password:
return jsonify({"error": "Username and password are required."}), 400
if role not in ROLE_OPTIONS:
return jsonify({"error": "Role must be admin or viewer."}), 400
if User.query.filter(db.func.lower(User.username) == username.lower()).first():
return jsonify({"error": "That username already exists."}), 400
user = User(username=username, password_hash=generate_password_hash(password), role=role)
db.session.add(user)
db.session.commit()
return jsonify(user.to_dict()), 201
@app.put("/api/users/<int:user_id>")
@admin_required
def update_user(user_id):
user = User.query.get_or_404(user_id)
payload = request.get_json(force=True)
username = (payload.get("username") or "").strip()
password = payload.get("password") or ""
role = (payload.get("role") or "").strip()
if not username:
return jsonify({"error": "Username is required."}), 400
if role not in ROLE_OPTIONS:
return jsonify({"error": "Role must be admin or viewer."}), 400
clash = User.query.filter(
db.func.lower(User.username) == username.lower(), User.id != user_id
).first()
if clash:
return jsonify({"error": "That username already exists."}), 400
if user.role == "admin" and role != "admin":
remaining_admins = User.query.filter(User.role == "admin", User.id != user_id).count()
if remaining_admins == 0:
return jsonify({"error": "At least one admin account must remain."}), 400
user.username = username
user.role = role
if password:
user.password_hash = generate_password_hash(password)
db.session.commit()
# Keep the active session in sync if an admin edits their own account.
if user.id == session.get("user_id"):
session["username"] = user.username
session["role"] = user.role
return jsonify(user.to_dict())
@app.delete("/api/users/<int:user_id>")
@admin_required
def delete_user(user_id):
user = User.query.get_or_404(user_id)
if user.id == session.get("user_id"):
return jsonify({"error": "You can't delete your own account while logged in."}), 400
if user.role == "admin":
remaining_admins = User.query.filter(User.role == "admin", User.id != user_id).count()
if remaining_admins == 0:
return jsonify({"error": "At least one admin account must remain."}), 400
db.session.delete(user)
db.session.commit()
return jsonify({"ok": True})
app = create_app()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 8000)), debug=True)
+55
View File
@@ -0,0 +1,55 @@
"""
One-off bulk update: set Current Owner for a list of phone numbers, without touching any other
field (Plan, Venue, Contract Expiration, Eligibility, Comment are left exactly as they are).
Usage:
python apply_owner_mapping.py /path/to/telco.db mapping.csv
mapping.csv has two columns: phone_number,current_owner (phone_number in the same raw format as
the source Excel - digits only, with or without the leading 0; it is normalized the same way the
original import did).
"""
import argparse
import csv
import sqlite3
def normalize_phone(value):
s = str(value).strip()
if s.isdigit() and not s.startswith("0"):
s = "0" + s
return s or None
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("db_path")
parser.add_argument("mapping_csv")
args = parser.parse_args()
con = sqlite3.connect(args.db_path)
cur = con.cursor()
updated, missing = [], []
with open(args.mapping_csv, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
phone_number = normalize_phone(row["phone_number"])
owner = row["current_owner"]
cur.execute("UPDATE phones SET current_owner = ? WHERE phone_number = ?", (owner, phone_number))
if cur.rowcount == 0:
missing.append(phone_number)
else:
updated.append(phone_number)
con.commit()
con.close()
print(f"Updated {len(updated)} rows.")
if missing:
print(f"No matching phone found for {len(missing)} entries: {missing}")
if __name__ == "__main__":
main()
+157
View File
@@ -0,0 +1,157 @@
"""
One-time migration: load the mobile phone repository from the source Excel file into the
SQLite database. Run this once; after that the app reads/writes only the SQLite database and
the Excel file can be forgotten.
Usage:
python import_excel.py "/path/to/Mobilne telefony 20260901.xlsx" [--mapping mapping.csv] [--reset]
Expected Excel columns (Slovak headers, in this exact order in the source file):
T.C. -> Phone Number
Meno uzivatela -> raw text, NOT auto-split into Owner/Venue (see README)
Uzivatelsky balik -> Plan
Viazanost do: -> Contract Expiration
Moznost vymeny od: -> Eligibility for Replacement (blank / '---' = eligible now)
Optional --mapping CSV (phone_number,current_owner,venue) backfills Current Owner / Venue for
matching phone numbers - this is the recommended way to fix up ownership since the Excel column
doesn't cleanly separate the two for most rows.
"""
import argparse
import csv
import sys
from datetime import date, datetime
import openpyxl
from app import create_app
from models import Phone, db
EXPECTED_HEADERS = [
"T.Č.",
"Meno užívatela",
"Užívateľský balík",
"Viazanosť do:",
"Možnost výmeny od:",
]
def normalize_phone(value):
"""Excel stores the subscriber number without the leading 0 (numeric cell). Re-add it."""
if value is None:
return None
s = str(value).strip()
if s.isdigit() and not s.startswith("0"):
s = "0" + s
return s or None
def normalize_date(value):
if value is None:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
s = str(value).strip()
if not s or s == "---":
return None
for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y"):
try:
return datetime.strptime(s, fmt).date()
except ValueError:
continue
print(f" warning: could not parse date '{value}', leaving blank", file=sys.stderr)
return None
def load_mapping(path):
mapping = {}
if not path:
return mapping
with open(path, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
phone = normalize_phone(row.get("phone_number"))
if not phone:
continue
mapping[phone] = {
"current_owner": (row.get("current_owner") or "").strip() or None,
"venue": (row.get("venue") or "").strip() or None,
}
return mapping
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("excel_path", help="Path to the source .xlsx file")
parser.add_argument("--mapping", help="Optional CSV with phone_number,current_owner,venue to backfill Owner/Venue")
parser.add_argument("--reset", action="store_true", help="Delete all existing phone rows before importing")
args = parser.parse_args()
mapping = load_mapping(args.mapping)
wb = openpyxl.load_workbook(args.excel_path, data_only=True)
ws = wb.active
rows = list(ws.iter_rows(values_only=True))
header = [str(h).strip() if h is not None else "" for h in rows[0]]
print(f"Sheet '{ws.title}' header: {header}")
app = create_app()
with app.app_context():
if args.reset:
deleted = Phone.query.delete()
db.session.commit()
print(f"Deleted {deleted} existing phone rows (--reset).")
created, updated, skipped = 0, 0, 0
for row in rows[1:]:
if row is None or all(v is None for v in row):
continue
raw_phone, raw_name, raw_plan, raw_expiration, raw_eligible = (list(row) + [None] * 5)[:5]
phone_number = normalize_phone(raw_phone)
if not phone_number:
skipped += 1
continue
plan = str(raw_plan).strip() if raw_plan else None
contract_expiration = normalize_date(raw_expiration)
eligible_for_replacement = normalize_date(raw_eligible)
backfill = mapping.get(phone_number, {})
current_owner = backfill.get("current_owner")
venue = backfill.get("venue")
# No mapping supplied for this number yet: keep the raw Excel text around in
# Current Owner so nothing is lost, and leave Venue for manual/edit-later cleanup.
if not mapping and raw_name:
current_owner = str(raw_name).strip()
existing = Phone.query.filter_by(phone_number=phone_number).one_or_none()
if existing:
existing.plan = plan
existing.contract_expiration = contract_expiration
existing.eligible_for_replacement = eligible_for_replacement
if backfill:
existing.current_owner = current_owner
existing.venue = venue
updated += 1
else:
db.session.add(Phone(
phone_number=phone_number,
plan=plan,
contract_expiration=contract_expiration,
eligible_for_replacement=eligible_for_replacement,
current_owner=current_owner,
venue=venue,
))
created += 1
db.session.commit()
print(f"Import complete: {created} created, {updated} updated, {skipped} skipped (no phone number).")
if __name__ == "__main__":
main()
+74
View File
@@ -0,0 +1,74 @@
from datetime import datetime
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
class Phone(db.Model):
__tablename__ = "phones"
id = db.Column(db.Integer, primary_key=True)
phone_number = db.Column(db.String(32), unique=True, nullable=False, index=True)
plan = db.Column(db.String(120), nullable=True)
contract_expiration = db.Column(db.Date, nullable=True)
eligible_for_replacement = db.Column(db.Date, nullable=True)
current_owner = db.Column(db.String(200), nullable=True)
venue = db.Column(db.String(200), nullable=True)
verified = db.Column(db.String(8), nullable=True)
comment = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def to_dict(self):
return {
"id": self.id,
"phone_number": self.phone_number,
"plan": self.plan,
"contract_expiration": self.contract_expiration.isoformat() if self.contract_expiration else None,
"eligible_for_replacement": self.eligible_for_replacement.isoformat() if self.eligible_for_replacement else None,
"current_owner": self.current_owner,
"venue": self.venue,
"verified": self.verified,
"comment": self.comment,
}
class User(db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
role = db.Column(db.String(20), nullable=False, default="viewer") # "admin" or "viewer"
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def to_dict(self):
return {
"id": self.id,
"username": self.username,
"role": self.role,
}
class InternetConnection(db.Model):
__tablename__ = "internet_connections"
id = db.Column(db.Integer, primary_key=True)
venue = db.Column(db.String(200), nullable=False)
isp_provider = db.Column(db.String(200), nullable=True)
plan = db.Column(db.String(200), nullable=True)
contract_expiration = db.Column(db.Date, nullable=True)
notes = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def to_dict(self):
return {
"id": self.id,
"venue": self.venue,
"isp_provider": self.isp_provider,
"plan": self.plan,
"contract_expiration": self.contract_expiration.isoformat() if self.contract_expiration else None,
"notes": self.notes,
}
+4
View File
@@ -0,0 +1,4 @@
Flask==3.0.3
Flask-SQLAlchemy==3.1.1
openpyxl==3.1.5
gunicorn==22.0.0
+341
View File
@@ -0,0 +1,341 @@
body {
background-color: #f4f6f8;
}
.navbar-brand {
font-weight: 600;
}
.table td, .table th {
vertical-align: middle;
}
.card-title {
font-weight: 600;
}
.icon {
vertical-align: -0.125em;
margin-right: 0.35rem;
}
h2 .icon {
width: 1.3em;
height: 1.3em;
}
.carrier-badge {
display: inline-block;
padding: 0.35em 0.75em;
font-size: 0.8rem;
font-weight: 600;
color: #fff;
background-color: #ff7900;
border-radius: 0.375rem;
vertical-align: middle;
}
.user-pill {
font-size: 0.85rem;
color: #fff !important;
opacity: 0.9;
}
/* ------------------------------------------------------------------ */
/* Login page */
/* ------------------------------------------------------------------ */
.login-body {
background-color: #f4f6f8;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-wrap {
width: 100%;
max-width: 400px;
padding: 1.5rem;
}
.login-card {
background: #fff;
border-radius: 1rem;
box-shadow: 0 0.75rem 2rem rgba(20, 40, 60, 0.12);
padding: 2.25rem 2rem;
}
.login-logo {
max-height: 56px;
width: auto;
}
/* ------------------------------------------------------------------ */
/* Home page: logo + animated tiles */
/* ------------------------------------------------------------------ */
.hero-logo {
max-height: 70px;
width: auto;
}
@keyframes fadeInDown {
from { opacity: 0; transform: translateY(-14px); }
to { opacity: 1; transform: translateY(0); }
}
.hero-fade-in {
animation: fadeInDown 0.6s ease both;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
.tile-card {
border: none;
border-radius: 1rem;
color: inherit;
animation: fadeInUp 0.55s ease both;
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
/* Stagger the entrance animation for each tile in the grid. */
.row > div:nth-child(1) .tile-card { animation-delay: 0.05s; }
.row > div:nth-child(2) .tile-card { animation-delay: 0.18s; }
.row > div:nth-child(3) .tile-card { animation-delay: 0.31s; }
.tile-card:hover,
.tile-card:focus-visible {
transform: translateY(-8px) scale(1.015);
box-shadow: 0 1.25rem 2.5rem rgba(20, 40, 60, 0.15) !important;
}
.tile-icon-wrap {
width: 84px;
height: 84px;
margin: 0 auto 1.25rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s ease;
}
.tile-icon-wrap svg {
width: 40px;
height: 40px;
}
.tile-card:hover .tile-icon-wrap {
transform: scale(1.12) rotate(-6deg);
}
.tile-phones .tile-icon-wrap {
color: #fff;
background: linear-gradient(135deg, #0d6efd, #6ea8fe);
}
.tile-internet .tile-icon-wrap {
color: #fff;
background: linear-gradient(135deg, #198754, #63e6be);
}
@media (prefers-reduced-motion: reduce) {
.hero-fade-in, .tile-card, .tile-icon-wrap {
animation: none !important;
transition: none !important;
}
}
/* ------------------------------------------------------------------ */
/* Mobile Phones page */
/* ------------------------------------------------------------------ */
.stat-card {
display: flex;
align-items: center;
gap: 0.9rem;
background: #fff;
border-radius: 0.9rem;
padding: 1rem 1.15rem;
box-shadow: 0 0.25rem 0.75rem rgba(20, 40, 60, 0.06);
animation: fadeInUp 0.5s ease both;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.stat-card:hover {
transform: translateY(-3px);
box-shadow: 0 0.5rem 1.25rem rgba(20, 40, 60, 0.1);
}
.row.g-3 > div:nth-child(1) .stat-card { animation-delay: 0.02s; }
.row.g-3 > div:nth-child(2) .stat-card { animation-delay: 0.09s; }
.row.g-3 > div:nth-child(3) .stat-card { animation-delay: 0.16s; }
.row.g-3 > div:nth-child(4) .stat-card { animation-delay: 0.23s; }
.row.g-3 > div:nth-child(5) .stat-card { animation-delay: 0.30s; }
.row.g-3 > div:nth-child(6) .stat-card { animation-delay: 0.37s; }
.row.g-3 > div:nth-child(7) .stat-card { animation-delay: 0.44s; }
.row.g-3 > div:nth-child(8) .stat-card { animation-delay: 0.51s; }
.stat-icon {
flex: none;
width: 46px;
height: 46px;
border-radius: 0.7rem;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.stat-icon svg { width: 22px; height: 22px; }
.stat-icon-total { background: linear-gradient(135deg, #0d6efd, #6ea8fe); }
.stat-icon-warning { background: linear-gradient(135deg, #fd7e14, #ffc078); }
.stat-icon-danger { background: linear-gradient(135deg, #dc3545, #ff8a94); }
.stat-icon-success { background: linear-gradient(135deg, #198754, #63e6be); }
.stat-value {
font-size: 1.5rem;
font-weight: 700;
line-height: 1.1;
}
.stat-label {
font-size: 0.8rem;
color: #6c757d;
}
.modern-card {
background: #fff;
border-radius: 1rem;
box-shadow: 0 0.25rem 0.75rem rgba(20, 40, 60, 0.06);
overflow: hidden;
animation: fadeInUp 0.5s ease both;
animation-delay: 0.28s;
}
.stat-icon-headoffice { background: linear-gradient(135deg, #6f42c1, #b197fc); }
.stat-icon-restaurant { background: linear-gradient(135deg, #d63384, #ff8fb1); }
.stat-icon-verified { background: linear-gradient(135deg, #0ca678, #63e6be); }
.stat-icon-unverified { background: linear-gradient(135deg, #868e96, #ced4da); }
.table-toolbar {
padding: 1rem 1.25rem;
border-bottom: 1px solid #eef0f2;
}
.search-box {
position: relative;
max-width: 360px;
flex: 1 1 260px;
}
.search-box svg {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
color: #adb5bd;
}
.search-box .form-control {
padding-left: 2.25rem;
border-radius: 2rem;
background-color: #f4f6f8;
border-color: transparent;
}
.search-box .form-control:focus {
background-color: #fff;
}
.modern-table thead th {
background-color: #f8f9fb;
color: #6c757d;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 700;
border-bottom: 2px solid #eef0f2;
padding: 0.85rem 0.9rem;
white-space: nowrap;
}
.modern-table tbody td {
padding: 0.75rem 0.9rem;
border-bottom: 1px solid #f1f3f5;
white-space: nowrap;
}
.modern-table tbody td.text-truncate {
white-space: nowrap; /* already implied by .text-truncate, kept explicit for clarity */
}
.modern-table tbody tr {
transition: background-color 0.15s ease;
}
.sort-link {
color: inherit;
text-decoration: none;
}
.sort-link:hover {
color: #0d6efd;
}
.sort-inactive {
opacity: 0.35;
}
.sort-active {
color: #0d6efd;
}
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
padding: 0;
border-radius: 0.6rem;
border: 1px solid transparent;
background: transparent;
transition: background-color 0.15s ease, transform 0.15s ease;
}
.btn-icon svg { width: 16px; height: 16px; }
.btn-icon:hover {
transform: translateY(-1px);
}
.btn-icon-primary {
color: #0d6efd;
}
.btn-icon-primary:hover {
background-color: #e7f1ff;
}
.btn-icon-danger {
color: #dc3545;
}
.btn-icon-danger:hover {
background-color: #fdeced;
}
@media (prefers-reduced-motion: reduce) {
.stat-card, .modern-card {
animation: none !important;
transition: none !important;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

+2
View File
@@ -0,0 +1,2 @@
// Shared site-wide JS (currently empty - page-specific behavior lives in each template's
// {% block scripts %}).
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+12
View File
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}Access Denied - Queensway Europe Telco Portal{% endblock %}
{% block content %}
<div class="modern-card p-5 text-center mx-auto" style="max-width: 480px;">
<div class="stat-icon stat-icon-danger mx-auto mb-3" style="width: 56px; height: 56px;">
{% include "icons/shield-lock-fill.svg" %}
</div>
<h4 class="fw-bold mb-2">Access Denied</h4>
<p class="text-muted mb-4">Your account has view-only access. Only admins can manage this page.</p>
<a href="/" class="btn btn-primary">Back to Home</a>
</div>
{% endblock %}
+60
View File
@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Queensway Europe Telco Portal{% endblock %}</title>
<link rel="stylesheet" href="/static/vendor/bootstrap/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/site.css">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
<div class="container-fluid">
<a class="navbar-brand" href="/">Queensway Europe Telco Portal</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navMain">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link {% if active == 'phones' %}active{% endif %}" href="/phones">
{% include "icons/phone.svg" %} Mobile Phones
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active == 'internet' %}active{% endif %}" href="/internet">
{% include "icons/wifi.svg" %} Internet Connectivity
</a>
</li>
{% if session.role == 'admin' %}
<li class="nav-item">
<a class="nav-link {% if active == 'users' %}active{% endif %}" href="/users">
{% include "icons/people.svg" %} Users
</a>
</li>
{% endif %}
</ul>
{% if session.user_id %}
<ul class="navbar-nav ms-auto align-items-lg-center">
<li class="nav-item">
<span class="nav-link disabled user-pill">
{{ session.username }}
<span class="badge rounded-pill bg-light text-dark ms-1">{{ session.role }}</span>
</span>
</li>
<li class="nav-item">
<a class="nav-link" href="/logout">{% include "icons/box-arrow-right.svg" %} Logout</a>
</li>
</ul>
{% endif %}
</div>
</div>
</nav>
<div class="container-fluid px-4 pb-5">
{% block content %}{% endblock %}
</div>
<script src="/static/vendor/bootstrap/bootstrap.bundle.min.js"></script>
<script src="/static/js/site.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-box-arrow-right" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M10 12.5a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v2a.5.5 0 0 0 1 0v-2A1.5 1.5 0 0 0 9.5 2h-8A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-2a.5.5 0 0 0-1 0z"/>
<path fill-rule="evenodd" d="M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 0 0-.708.708L14.293 7.5H5.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708z"/>
</svg>

After

Width:  |  Height:  |  Size: 548 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-building" viewBox="0 0 16 16">
<path d="M4 2.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5zm3 0a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5zm3.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5zM4 5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5zM7.5 5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5zm2.5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5zM4.5 8a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5zm2.5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5zm3.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5z"/>
<path d="M2 1a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1zm11 0H3v14h3v-2.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5V15h3z"/>
</svg>

After

Width:  |  Height:  |  Size: 1011 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-calendar-check" viewBox="0 0 16 16">
<path d="M10.854 7.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 9.793l2.646-2.647a.5.5 0 0 1 .708 0"/>
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5M1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4z"/>
</svg>

After

Width:  |  Height:  |  Size: 468 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-calendar-x" viewBox="0 0 16 16">
<path d="M6.146 7.146a.5.5 0 0 1 .708 0L8 8.293l1.146-1.147a.5.5 0 1 1 .708.708L8.707 9l1.147 1.146a.5.5 0 0 1-.708.708L8 9.707l-1.146 1.147a.5.5 0 0 1-.708-.708L7.293 9 6.146 7.854a.5.5 0 0 1 0-.708"/>
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5M1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4z"/>
</svg>

After

Width:  |  Height:  |  Size: 530 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-collection" viewBox="0 0 16 16">
<path d="M2.5 3.5a.5.5 0 0 1 0-1h11a.5.5 0 0 1 0 1zm2-2a.5.5 0 0 1 0-1h7a.5.5 0 0 1 0 1zM0 13a1.5 1.5 0 0 0 1.5 1.5h13A1.5 1.5 0 0 0 16 13V6a1.5 1.5 0 0 0-1.5-1.5h-13A1.5 1.5 0 0 0 0 6zm1.5.5A.5.5 0 0 1 1 13V6a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 406 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-exclamation-triangle" viewBox="0 0 16 16">
<path d="M7.938 2.016A.13.13 0 0 1 8.002 2a.13.13 0 0 1 .063.016.15.15 0 0 1 .054.057l6.857 11.667c.036.06.035.124.002.183a.2.2 0 0 1-.054.06.1.1 0 0 1-.066.017H1.146a.1.1 0 0 1-.066-.017.2.2 0 0 1-.054-.06.18.18 0 0 1 .002-.183L7.884 2.073a.15.15 0 0 1 .054-.057m1.044-.45a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767z"/>
<path d="M7.002 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0M7.1 5.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0z"/>
</svg>

After

Width:  |  Height:  |  Size: 639 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-patch-check" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M10.354 6.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7 8.793l2.646-2.647a.5.5 0 0 1 .708 0"/>
<path d="m10.273 2.513-.921-.944.715-.698.622.637.89-.011a2.89 2.89 0 0 1 2.924 2.924l-.01.89.636.622a2.89 2.89 0 0 1 0 4.134l-.637.622.011.89a2.89 2.89 0 0 1-2.924 2.924l-.89-.01-.622.636a2.89 2.89 0 0 1-4.134 0l-.622-.637-.89.011a2.89 2.89 0 0 1-2.924-2.924l.01-.89-.636-.622a2.89 2.89 0 0 1 0-4.134l.637-.622-.011-.89a2.89 2.89 0 0 1 2.924-2.924l.89.01.622-.636a2.89 2.89 0 0 1 4.134 0l-.715.698a1.89 1.89 0 0 0-2.704 0l-.92.944-1.32-.016a1.89 1.89 0 0 0-1.911 1.912l.016 1.318-.944.921a1.89 1.89 0 0 0 0 2.704l.944.92-.016 1.32a1.89 1.89 0 0 0 1.912 1.911l1.318-.016.921.944a1.89 1.89 0 0 0 2.704 0l.92-.944 1.32.016a1.89 1.89 0 0 0 1.911-1.912l-.016-1.318.944-.921a1.89 1.89 0 0 0 0-2.704l-.944-.92.016-1.32a1.89 1.89 0 0 0-1.912-1.911z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-patch-exclamation" viewBox="0 0 16 16">
<path d="M7.001 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0M7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.553.553 0 0 1-1.1 0z"/>
<path d="m10.273 2.513-.921-.944.715-.698.622.637.89-.011a2.89 2.89 0 0 1 2.924 2.924l-.01.89.636.622a2.89 2.89 0 0 1 0 4.134l-.637.622.011.89a2.89 2.89 0 0 1-2.924 2.924l-.89-.01-.622.636a2.89 2.89 0 0 1-4.134 0l-.622-.637-.89.011a2.89 2.89 0 0 1-2.924-2.924l.01-.89-.636-.622a2.89 2.89 0 0 1 0-4.134l.637-.622-.011-.89a2.89 2.89 0 0 1 2.924-2.924l.89.01.622-.636a2.89 2.89 0 0 1 4.134 0l-.715.698a1.89 1.89 0 0 0-2.704 0l-.92.944-1.32-.016a1.89 1.89 0 0 0-1.911 1.912l.016 1.318-.944.921a1.89 1.89 0 0 0 0 2.704l.944.92-.016 1.32a1.89 1.89 0 0 0 1.912 1.911l1.318-.016.921.944a1.89 1.89 0 0 0 2.704 0l.92-.944 1.32.016a1.89 1.89 0 0 0 1.911-1.912l-.016-1.318.944-.921a1.89 1.89 0 0 0 0-2.704l-.944-.92.016-1.32a1.89 1.89 0 0 0-1.912-1.911z"/>
</svg>

After

Width:  |  Height:  |  Size: 1006 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pencil-square" viewBox="0 0 16 16">
<path d="M15.502 1.94a.5.5 0 0 1 0 .706L14.459 3.69l-2-2L13.502.646a.5.5 0 0 1 .707 0l1.293 1.293zm-1.75 2.456-2-2L4.939 9.21a.5.5 0 0 0-.121.196l-.805 2.414a.25.25 0 0 0 .316.316l2.414-.805a.5.5 0 0 0 .196-.12l6.813-6.814z"/>
<path fill-rule="evenodd" d="M1 13.5A1.5 1.5 0 0 0 2.5 15h11a1.5 1.5 0 0 0 1.5-1.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5v-11a.5.5 0 0 1 .5-.5H9a.5.5 0 0 0 0-1H2.5A1.5 1.5 0 0 0 1 2.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 576 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-people" viewBox="0 0 16 16">
<path d="M15 14s1 0 1-1-1-4-5-4-5 3-5 4 1 1 1 1zm-7.978-1L7 12.996c.001-.264.167-1.03.76-1.72C8.312 10.629 9.282 10 11 10c1.717 0 2.687.63 3.24 1.276.593.69.758 1.457.76 1.72l-.008.002-.014.002zM11 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4m3-2a3 3 0 1 1-6 0 3 3 0 0 1 6 0M6.936 9.28a6 6 0 0 0-1.23-.247A7 7 0 0 0 5 9c-4 0-5 3-5 4q0 1 1 1h4.216A2.24 2.24 0 0 1 5 13c0-1.01.377-2.042 1.09-2.904.243-.294.526-.569.846-.816M4.92 10A5.5 5.5 0 0 0 4 13H1c0-.26.164-1.03.76-1.724.545-.636 1.492-1.256 3.16-1.275ZM1.5 5.5a3 3 0 1 1 6 0 3 3 0 0 1-6 0m3-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4"/>
</svg>

After

Width:  |  Height:  |  Size: 699 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-phone-fill" viewBox="0 0 16 16">
<path d="M3 2a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zm6 11a1 1 0 1 0-2 0 1 1 0 0 0 2 0"/>
</svg>

After

Width:  |  Height:  |  Size: 252 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-phone icon" viewBox="0 0 16 16">
<path d="M11 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zM5 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z"/>
<path d="M8 14a1 1 0 1 0 0-2 1 1 0 0 0 0 2"/>
</svg>

After

Width:  |  Height:  |  Size: 336 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-circle" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4"/>
</svg>

After

Width:  |  Height:  |  Size: 334 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-router icon" viewBox="0 0 16 16">
<path d="M5.525 3.025a3.5 3.5 0 0 1 4.95 0 .5.5 0 1 0 .707-.707 4.5 4.5 0 0 0-6.364 0 .5.5 0 0 0 .707.707"/>
<path d="M6.94 4.44a1.5 1.5 0 0 1 2.12 0 .5.5 0 0 0 .708-.708 2.5 2.5 0 0 0-3.536 0 .5.5 0 0 0 .707.707ZM2.5 11a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1m4.5-.5a.5.5 0 1 0 1 0 .5.5 0 0 0-1 0m2.5.5a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1m1.5-.5a.5.5 0 1 0 1 0 .5.5 0 0 0-1 0m2 0a.5.5 0 1 0 1 0 .5.5 0 0 0-1 0"/>
<path d="M2.974 2.342a.5.5 0 1 0-.948.316L3.806 8H1.5A1.5 1.5 0 0 0 0 9.5v2A1.5 1.5 0 0 0 1.5 13H2a.5.5 0 0 0 .5.5h2A.5.5 0 0 0 5 13h6a.5.5 0 0 0 .5.5h2a.5.5 0 0 0 .5-.5h.5a1.5 1.5 0 0 0 1.5-1.5v-2A1.5 1.5 0 0 0 14.5 8h-2.306l1.78-5.342a.5.5 0 1 0-.948-.316L11.14 8H4.86zM14.5 9a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5h-13a.5.5 0 0 1-.5-.5v-2a.5.5 0 0 1 .5-.5z"/>
<path d="M8.5 5.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0"/>
</svg>

After

Width:  |  Height:  |  Size: 952 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-search" viewBox="0 0 16 16">
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001q.044.06.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1 1 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0"/>
</svg>

After

Width:  |  Height:  |  Size: 316 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-shield-lock-fill" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 0c-.69 0-1.843.265-2.928.56-1.11.3-2.229.655-2.887.87a1.54 1.54 0 0 0-1.044 1.262c-.596 4.477.787 7.795 2.465 9.99a11.8 11.8 0 0 0 2.517 2.453c.386.273.744.482 1.048.625.28.132.581.24.829.24s.548-.108.829-.24a7 7 0 0 0 1.048-.625 11.8 11.8 0 0 0 2.517-2.453c1.678-2.195 3.061-5.513 2.465-9.99a1.54 1.54 0 0 0-1.044-1.263 63 63 0 0 0-2.887-.87C9.843.266 8.69 0 8 0m0 5a1.5 1.5 0 0 1 .5 2.915l.385 1.99a.5.5 0 0 1-.491.595h-.788a.5.5 0 0 1-.49-.595l.384-1.99A1.5 1.5 0 0 1 8 5"/>
</svg>

After

Width:  |  Height:  |  Size: 654 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-shop" viewBox="0 0 16 16">
<path d="M2.97 1.35A1 1 0 0 1 3.73 1h8.54a1 1 0 0 1 .76.35l2.609 3.044A1.5 1.5 0 0 1 16 5.37v.255a2.375 2.375 0 0 1-4.25 1.458A2.37 2.37 0 0 1 9.875 8 2.37 2.37 0 0 1 8 7.083 2.37 2.37 0 0 1 6.125 8a2.37 2.37 0 0 1-1.875-.917A2.375 2.375 0 0 1 0 5.625V5.37a1.5 1.5 0 0 1 .361-.976zm1.78 4.275a1.375 1.375 0 0 0 2.75 0 .5.5 0 0 1 1 0 1.375 1.375 0 0 0 2.75 0 .5.5 0 0 1 1 0 1.375 1.375 0 1 0 2.75 0V5.37a.5.5 0 0 0-.12-.325L12.27 2H3.73L1.12 5.045A.5.5 0 0 0 1 5.37v.255a1.375 1.375 0 0 0 2.75 0 .5.5 0 0 1 1 0M1.5 8.5A.5.5 0 0 1 2 9v6h1v-5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v5h6V9a.5.5 0 0 1 1 0v6h.5a.5.5 0 0 1 0 1H.5a.5.5 0 0 1 0-1H1V9a.5.5 0 0 1 .5-.5M4 15h3v-5H4zm5-5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1zm3 0h-2v3h2z"/>
</svg>

After

Width:  |  Height:  |  Size: 878 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0z"/>
<path d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4zM2.5 3h11V2h-11z"/>
</svg>

After

Width:  |  Height:  |  Size: 540 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-wifi icon" viewBox="0 0 16 16">
<path d="M15.384 6.115a.485.485 0 0 0-.047-.736A12.44 12.44 0 0 0 8 3C5.259 3 2.723 3.882.663 5.379a.485.485 0 0 0-.048.736.52.52 0 0 0 .668.05A11.45 11.45 0 0 1 8 4c2.507 0 4.827.802 6.716 2.164.205.148.49.13.668-.049"/>
<path d="M13.229 8.271a.482.482 0 0 0-.063-.745A9.46 9.46 0 0 0 8 6c-1.905 0-3.68.56-5.166 1.526a.48.48 0 0 0-.063.745.525.525 0 0 0 .652.065A8.46 8.46 0 0 1 8 7a8.46 8.46 0 0 1 4.576 1.336c.206.132.48.108.653-.065m-2.183 2.183c.226-.226.185-.605-.1-.75A6.5 6.5 0 0 0 8 9c-1.06 0-2.062.254-2.946.704-.285.145-.326.524-.1.75l.015.015c.16.16.407.19.611.09A5.5 5.5 0 0 1 8 10c.868 0 1.69.201 2.42.56.203.1.45.07.61-.091zM9.06 12.44c.196-.196.198-.52-.04-.66A2 2 0 0 0 8 11.5a2 2 0 0 0-1.02.28c-.238.14-.236.464-.04.66l.706.706a.5.5 0 0 0 .707 0l.707-.707z"/>
</svg>

After

Width:  |  Height:  |  Size: 917 B

+40
View File
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Queensway Europe Telco Portal{% endblock %}
{% block content %}
<div class="text-center mb-5 hero-fade-in">
<img src="/static/img/queensway-europe-logo.png" alt="Queensway Europe" class="hero-logo mb-3">
<h1 class="display-6 fw-bold mb-1">Telco Portal</h1>
<p class="text-muted mb-0">Mobile phones and restaurant internet connectivity, in one place.</p>
</div>
<div class="row g-4 justify-content-center">
<div class="col-md-5">
<a href="/phones" class="text-decoration-none">
<div class="card tile-card tile-phones shadow-sm h-100">
<div class="card-body text-center py-5">
<div class="tile-icon-wrap">
{% include "icons/phone-fill.svg" %}
</div>
<h5 class="card-title">Mobile Phones</h5>
<p class="card-text text-muted">Phone numbers, plans, contract expiration, replacement eligibility, owner and venue.</p>
<span class="btn btn-primary">Open</span>
</div>
</div>
</a>
</div>
<div class="col-md-5">
<a href="/internet" class="text-decoration-none">
<div class="card tile-card tile-internet shadow-sm h-100">
<div class="card-body text-center py-5">
<div class="tile-icon-wrap">
{% include "icons/router.svg" %}
</div>
<h5 class="card-title">Internet Connectivity</h5>
<p class="card-text text-muted">Internet connections at each restaurant venue.</p>
<span class="btn btn-primary">Open</span>
</div>
</div>
</a>
</div>
</div>
{% endblock %}
+148
View File
@@ -0,0 +1,148 @@
{% extends "base.html" %}
{% block title %}Internet Connectivity - Queensway Europe Telco Portal{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="mb-0 d-flex align-items-center gap-2">{% include "icons/wifi.svg" %} Internet Connectivity</h2>
{% if session.role == 'admin' %}
<button type="button" class="btn btn-success" onclick="openConnModal(null)">+ Add Connection</button>
{% endif %}
</div>
<div class="table-responsive">
<table class="table table-striped table-hover align-middle bg-white">
<thead class="table-dark">
<tr>
<th>Venue</th>
<th>ISP Provider</th>
<th>Plan</th>
<th>Contract Expiration</th>
<th>Notes</th>
{% if session.role == 'admin' %}<th class="text-end">Actions</th>{% endif %}
</tr>
</thead>
<tbody>
{% for c in connections %}
<tr>
<td>{{ c.venue }}</td>
<td>{{ c.isp_provider or "" }}</td>
<td>{{ c.plan or "" }}</td>
<td>{{ c.contract_expiration.strftime("%d-%m-%Y") if c.contract_expiration else "" }}</td>
<td>{{ c.notes or "" }}</td>
{% if session.role == 'admin' %}
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-primary"
onclick='openConnModal({{ c.to_dict() | tojson }})'>Edit</button>
<button type="button" class="btn btn-sm btn-outline-danger"
onclick="deleteConn({{ c.id }}, {{ c.venue | tojson }})">Delete</button>
</td>
{% endif %}
</tr>
{% else %}
<tr><td colspan="{{ 6 if session.role == 'admin' else 5 }}" class="text-center text-muted py-4">No internet connections recorded yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Edit / Add modal -->
<div class="modal fade" id="connModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form id="connForm">
<div class="modal-header">
<h5 class="modal-title" id="connModalTitle">Edit Connection</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="connId">
<div class="mb-3">
<label class="form-label">Venue</label>
<input type="text" class="form-control" id="connVenue" required>
</div>
<div class="mb-3">
<label class="form-label">ISP Provider</label>
<input type="text" class="form-control" id="connProvider">
</div>
<div class="mb-3">
<label class="form-label">Plan</label>
<input type="text" class="form-control" id="connPlan">
</div>
<div class="mb-3">
<label class="form-label">Contract Expiration</label>
<input type="date" class="form-control" id="connContractExpiration">
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<textarea class="form-control" id="connNotes" rows="3"></textarea>
</div>
<div id="connError" class="text-danger small"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const connModalEl = document.getElementById('connModal');
const connModal = new bootstrap.Modal(connModalEl);
function openConnModal(conn) {
document.getElementById('connForm').reset();
document.getElementById('connError').textContent = '';
if (conn) {
document.getElementById('connModalTitle').textContent = 'Edit Connection';
document.getElementById('connId').value = conn.id;
document.getElementById('connVenue').value = conn.venue || '';
document.getElementById('connProvider').value = conn.isp_provider || '';
document.getElementById('connPlan').value = conn.plan || '';
document.getElementById('connContractExpiration').value = conn.contract_expiration || '';
document.getElementById('connNotes').value = conn.notes || '';
} else {
document.getElementById('connModalTitle').textContent = 'Add Connection';
document.getElementById('connId').value = '';
}
connModal.show();
}
document.getElementById('connForm').addEventListener('submit', async function (e) {
e.preventDefault();
const id = document.getElementById('connId').value;
const payload = {
venue: document.getElementById('connVenue').value,
isp_provider: document.getElementById('connProvider').value,
plan: document.getElementById('connPlan').value,
contract_expiration: document.getElementById('connContractExpiration').value,
notes: document.getElementById('connNotes').value,
};
const url = id ? `/api/internet/${id}` : '/api/internet';
const method = id ? 'PUT' : 'POST';
const resp = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (resp.ok) {
window.location.reload();
} else {
const data = await resp.json().catch(() => ({}));
document.getElementById('connError').textContent = data.error || 'Failed to save. Please check the fields.';
}
});
async function deleteConn(id, venue) {
if (!confirm(`Delete internet connection for "${venue}"? This cannot be undone.`)) return;
const resp = await fetch(`/api/internet/${id}`, { method: 'DELETE' });
if (resp.ok) {
window.location.reload();
} else {
alert('Failed to delete.');
}
}
</script>
{% endblock %}
+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in - Queensway Europe Telco Portal</title>
<link rel="stylesheet" href="/static/vendor/bootstrap/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/site.css">
</head>
<body class="login-body">
<div class="login-wrap">
<div class="login-card hero-fade-in">
<div class="text-center mb-4">
<img src="/static/img/queensway-europe-logo.png" alt="Queensway Europe" class="login-logo mb-3">
<h1 class="h4 fw-bold mb-1 d-flex align-items-center justify-content-center gap-2">
{% include "icons/shield-lock-fill.svg" %} Telco Portal
</h1>
<p class="text-muted small mb-0">Sign in to continue</p>
</div>
{% if error %}
<div class="alert alert-danger py-2 small" role="alert">{{ error }}</div>
{% endif %}
<form method="post" action="/login">
<input type="hidden" name="next" value="{{ next }}">
<div class="mb-3">
<label class="form-label" for="username">Username</label>
<input type="text" class="form-control" id="username" name="username" autocomplete="username" required autofocus>
</div>
<div class="mb-4">
<label class="form-label" for="password">Password</label>
<input type="password" class="form-control" id="password" name="password" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn-primary w-100">Sign In</button>
</form>
</div>
</div>
</body>
</html>
+360
View File
@@ -0,0 +1,360 @@
{% extends "base.html" %}
{% block title %}Mobile Phones - Queensway Europe Telco Portal{% endblock %}
{% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-center mb-4 gap-2">
<h2 class="mb-0 d-flex align-items-center gap-2">
{% include "icons/phone.svg" %} Mobile Phones
<span class="carrier-badge" title="Carrier">Orange</span>
</h2>
{% if session.role == 'admin' %}
<button type="button" class="btn btn-success d-inline-flex align-items-center gap-2" onclick="openPhoneModal(null)">
{% include "icons/plus-circle.svg" %} Add Phone
</button>
{% endif %}
</div>
<div class="row g-3 mb-4">
<div class="col-6 col-lg-3">
<div class="stat-card">
<div class="stat-icon stat-icon-total">{% include "icons/collection.svg" %}</div>
<div>
<div class="stat-value">{{ stats.total }}</div>
<div class="stat-label">Total Phones</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-card">
<div class="stat-icon stat-icon-warning">{% include "icons/exclamation-triangle.svg" %}</div>
<div>
<div class="stat-value">{{ stats.expiring_soon }}</div>
<div class="stat-label">Expiring Soon</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-card">
<div class="stat-icon stat-icon-danger">{% include "icons/calendar-x.svg" %}</div>
<div>
<div class="stat-value">{{ stats.expired }}</div>
<div class="stat-label">Expired</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-card">
<div class="stat-icon stat-icon-success">{% include "icons/calendar-check.svg" %}</div>
<div>
<div class="stat-value">{{ stats.eligible_now }}</div>
<div class="stat-label">Eligible Now</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-card">
<div class="stat-icon stat-icon-headoffice">{% include "icons/building.svg" %}</div>
<div>
<div class="stat-value">{{ stats.headoffice }}</div>
<div class="stat-label">QWE Headoffice &middot; {{ stats.headoffice_pct }}%</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-card">
<div class="stat-icon stat-icon-restaurant">{% include "icons/shop.svg" %}</div>
<div>
<div class="stat-value">{{ stats.restaurant }}</div>
<div class="stat-label">KFC Restaurant &middot; {{ stats.restaurant_pct }}%</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-card">
<div class="stat-icon stat-icon-verified">{% include "icons/patch-check.svg" %}</div>
<div>
<div class="stat-value">{{ stats.verified }}</div>
<div class="stat-label">Verified</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-card">
<div class="stat-icon stat-icon-unverified">{% include "icons/patch-exclamation.svg" %}</div>
<div>
<div class="stat-value">{{ stats.not_verified }}</div>
<div class="stat-label">Not Verified</div>
</div>
</div>
</div>
</div>
<div class="modern-card mb-0">
<div class="table-toolbar d-flex flex-wrap align-items-center justify-content-between gap-2">
<div class="search-box">
{% include "icons/search.svg" %}
<input type="text" id="phoneSearch" class="form-control" placeholder="Search phones, owners, venues, comments...">
</div>
<div class="text-muted small" id="phoneRowCount"></div>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0 modern-table" id="phonesTable">
<thead>
<tr>
{% macro sort_link(field, label) -%}
{% set next_dir = 'desc' if sort == field and direction == 'asc' else 'asc' %}
<th>
<a href="{{ url_for('phones_page', sort=field, dir=next_dir) }}" class="sort-link d-inline-flex align-items-center gap-1">
{{ label }}
{% if sort == field %}
<span class="sort-active">{{ '▲' if direction == 'asc' else '▼' }}</span>
{% else %}
<span class="sort-inactive">↕</span>
{% endif %}
</a>
</th>
{%- endmacro %}
{{ sort_link('phone_number', 'Phone Number') }}
{{ sort_link('current_owner', 'Current Owner') }}
{{ sort_link('plan', 'Plan') }}
{{ sort_link('contract_expiration', 'Contract Expiration') }}
{{ sort_link('eligible_for_replacement', 'Eligibility for Replacement') }}
{{ sort_link('venue', 'Venue') }}
{{ sort_link('verified', 'Verified') }}
{{ sort_link('comment', 'Comment') }}
{% if session.role == 'admin' %}<th class="text-end">Actions</th>{% endif %}
</tr>
</thead>
<tbody>
{% for p in phones %}
<tr>
<td class="fw-semibold">{{ p.phone_number }}</td>
<td>{{ p.current_owner or "" }}</td>
<td>{{ p.plan or "" }}</td>
<td>
{% if p.contract_expiration %}
{% if p.contract_expiration < today %}
<span class="badge rounded-pill bg-danger-subtle text-danger-emphasis">Expired</span>
{% elif (p.contract_expiration - today).days <= 60 %}
<span class="badge rounded-pill bg-warning-subtle text-warning-emphasis">Expiring soon</span>
{% endif %}
<span class="d-block text-muted small mt-1">{{ p.contract_expiration.strftime("%d-%m-%Y") }}</span>
{% endif %}
</td>
<td>
{% if not p.eligible_for_replacement or p.eligible_for_replacement <= today %}
<span class="badge rounded-pill bg-success-subtle text-success-emphasis">Eligible now</span>
{% else %}
<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis">From {{ p.eligible_for_replacement.strftime("%d-%m-%Y") }}</span>
{% endif %}
</td>
<td>{% if p.venue %}<span class="badge rounded-pill text-bg-light border">{{ p.venue }}</span>{% endif %}</td>
<td>
{% if p.verified == "Yes" %}
<span class="badge rounded-pill bg-success-subtle text-success-emphasis">Yes</span>
{% elif p.verified == "No" %}
<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis">No</span>
{% endif %}
</td>
<td class="text-truncate" style="max-width: 220px;" title="{{ p.comment or '' }}">{{ p.comment or "" }}</td>
{% if session.role == 'admin' %}
<td class="text-end">
<div class="d-inline-flex gap-1">
<button type="button" class="btn btn-icon btn-icon-primary" title="Edit"
onclick='openPhoneModal({{ p.to_dict() | tojson }})'>{% include "icons/pencil-square.svg" %}</button>
<button type="button" class="btn btn-icon btn-icon-danger" title="Delete"
onclick="deletePhone({{ p.id }}, '{{ p.phone_number }}')">{% include "icons/trash.svg" %}</button>
</div>
</td>
{% endif %}
</tr>
{% else %}
<tr><td colspan="{{ 9 if session.role == 'admin' else 8 }}" class="text-center text-muted py-5">No phones yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div id="phoneNoResults" class="text-center text-muted py-5" hidden>No phones match your search.</div>
</div>
<!-- Edit / Add modal -->
<div class="modal fade" id="phoneModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form id="phoneForm">
<div class="modal-header">
<h5 class="modal-title" id="phoneModalTitle">Edit Phone</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="phoneId">
<div class="mb-3">
<label class="form-label">Phone Number</label>
<input type="text" class="form-control" id="phoneNumber" required>
<div class="form-text" id="phoneNumberHelp" hidden>Phone number can't be changed after the phone is added.</div>
</div>
<div class="mb-3">
<label class="form-label">Current Owner</label>
<input type="text" class="form-control" id="phoneOwner">
</div>
<div class="mb-3">
<label class="form-label">Plan</label>
<select class="form-select" id="phonePlan">
<option value="">-- Select Plan --</option>
{% for option in plan_options %}
<option value="{{ option }}">{{ option }}</option>
{% endfor %}
</select>
<div class="form-text" id="phonePlanHelp" hidden>Plan can't be changed after the phone is added.</div>
</div>
<div class="mb-3">
<label class="form-label">Contract Expiration</label>
<input type="date" class="form-control" id="phoneContractExpiration">
</div>
<div class="mb-3">
<label class="form-label">Eligibility for Replacement</label>
<input type="date" class="form-control" id="phoneEligible">
<div class="form-text">Leave blank if there is no restriction (eligible now).</div>
</div>
<div class="mb-3">
<label class="form-label">Venue</label>
<select class="form-select" id="phoneVenue">
<option value="">-- Select Venue --</option>
{% for option in venue_options %}
<option value="{{ option }}">{{ option }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Verified</label>
<select class="form-select" id="phoneVerified">
<option value="">-- Select --</option>
{% for option in verified_options %}
<option value="{{ option }}">{{ option }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Comment</label>
<textarea class="form-control" id="phoneComment" rows="3"></textarea>
</div>
<div id="phoneError" class="text-danger small"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const phoneModalEl = document.getElementById('phoneModal');
const phoneModal = new bootstrap.Modal(phoneModalEl);
function openPhoneModal(phone) {
document.getElementById('phoneForm').reset();
document.getElementById('phoneError').textContent = '';
const isEdit = !!phone;
const numberInput = document.getElementById('phoneNumber');
const planSelect = document.getElementById('phonePlan');
numberInput.readOnly = isEdit;
planSelect.disabled = isEdit;
document.getElementById('phoneNumberHelp').hidden = !isEdit;
document.getElementById('phonePlanHelp').hidden = !isEdit;
if (isEdit) {
document.getElementById('phoneModalTitle').textContent = 'Edit Phone';
document.getElementById('phoneId').value = phone.id;
numberInput.value = phone.phone_number || '';
planSelect.value = phone.plan || '';
document.getElementById('phoneContractExpiration').value = phone.contract_expiration || '';
document.getElementById('phoneEligible').value = phone.eligible_for_replacement || '';
document.getElementById('phoneOwner').value = phone.current_owner || '';
document.getElementById('phoneVenue').value = phone.venue || '';
document.getElementById('phoneVerified').value = phone.verified || '';
document.getElementById('phoneComment').value = phone.comment || '';
} else {
document.getElementById('phoneModalTitle').textContent = 'Add Phone';
document.getElementById('phoneId').value = '';
}
phoneModal.show();
}
document.getElementById('phoneForm').addEventListener('submit', async function (e) {
e.preventDefault();
const id = document.getElementById('phoneId').value;
const payload = {
phone_number: document.getElementById('phoneNumber').value,
plan: document.getElementById('phonePlan').value,
contract_expiration: document.getElementById('phoneContractExpiration').value,
eligible_for_replacement: document.getElementById('phoneEligible').value,
current_owner: document.getElementById('phoneOwner').value,
venue: document.getElementById('phoneVenue').value,
verified: document.getElementById('phoneVerified').value,
comment: document.getElementById('phoneComment').value,
};
const url = id ? `/api/phones/${id}` : '/api/phones';
const method = id ? 'PUT' : 'POST';
const resp = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (resp.ok) {
window.location.reload();
} else {
const data = await resp.json().catch(() => ({}));
document.getElementById('phoneError').textContent = data.error || 'Failed to save. Please check the fields.';
}
});
async function deletePhone(id, phoneNumber) {
if (!confirm(`Delete phone ${phoneNumber}? This cannot be undone.`)) return;
const resp = await fetch(`/api/phones/${id}`, { method: 'DELETE' });
if (resp.ok) {
window.location.reload();
} else {
alert('Failed to delete.');
}
}
// Live client-side search across every visible column of the phones table.
(function () {
const searchInput = document.getElementById('phoneSearch');
const table = document.getElementById('phonesTable');
const noResults = document.getElementById('phoneNoResults');
const rowCount = document.getElementById('phoneRowCount');
if (!searchInput || !table) return;
const rows = Array.from(table.querySelectorAll('tbody tr')).filter(tr => tr.children.length > 1);
const totalRows = rows.length;
// Strip accents/diacritics so a search for "n" also matches "ň", "z" matches "ž", etc.
function foldDiacritics(str) {
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
const rowText = rows.map(row => foldDiacritics(row.textContent.toLowerCase()));
function applyFilter() {
const q = foldDiacritics(searchInput.value.trim().toLowerCase());
let visible = 0;
rows.forEach((row, i) => {
const match = !q || rowText[i].includes(q);
row.hidden = !match;
if (match) visible++;
});
noResults.hidden = !(q && visible === 0);
table.closest('.table-responsive').hidden = q && visible === 0;
rowCount.textContent = q ? `${visible} of ${totalRows} phones` : `${totalRows} phones`;
}
searchInput.addEventListener('input', applyFilter);
applyFilter();
})();
</script>
{% endblock %}
+183
View File
@@ -0,0 +1,183 @@
{% extends "base.html" %}
{% block title %}Users - Queensway Europe Telco Portal{% endblock %}
{% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-center mb-4 gap-2">
<h2 class="mb-0 d-flex align-items-center gap-2">
{% include "icons/people.svg" %} Users
</h2>
<button type="button" class="btn btn-success d-inline-flex align-items-center gap-2" onclick="openUserModal(null)">
{% include "icons/plus-circle.svg" %} Add User
</button>
</div>
<div class="row g-3 mb-4">
<div class="col-6 col-lg-3">
<div class="stat-card">
<div class="stat-icon stat-icon-total">{% include "icons/collection.svg" %}</div>
<div>
<div class="stat-value">{{ users|length }}</div>
<div class="stat-label">Total Users</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-card">
<div class="stat-icon stat-icon-verified">{% include "icons/shield-lock-fill.svg" %}</div>
<div>
<div class="stat-value">{{ admin_count }}</div>
<div class="stat-label">Admins</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-card">
<div class="stat-icon stat-icon-headoffice">{% include "icons/people.svg" %}</div>
<div>
<div class="stat-value">{{ viewer_count }}</div>
<div class="stat-label">Viewers</div>
</div>
</div>
</div>
</div>
<div class="modern-card mb-0">
<div class="table-toolbar d-flex flex-wrap align-items-center justify-content-between gap-2">
<div class="text-muted small">Admins can add, edit and remove phones, internet connections and other user accounts. Viewers can only view.</div>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0 modern-table">
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td class="fw-semibold">{{ u.username }}{% if u.id == session.user_id %}<span class="text-muted fw-normal"> (you)</span>{% endif %}</td>
<td>
{% if u.role == "admin" %}
<span class="badge rounded-pill bg-success-subtle text-success-emphasis">Admin</span>
{% else %}
<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis">Viewer</span>
{% endif %}
</td>
<td class="text-end">
<div class="d-inline-flex gap-1">
<button type="button" class="btn btn-icon btn-icon-primary" title="Edit"
onclick='openUserModal({{ u.to_dict() | tojson }})'>{% include "icons/pencil-square.svg" %}</button>
<button type="button" class="btn btn-icon btn-icon-danger" title="Delete"
onclick="deleteUser({{ u.id }}, {{ u.username | tojson }})" {% if u.id == session.user_id %}disabled{% endif %}>{% include "icons/trash.svg" %}</button>
</div>
</td>
</tr>
{% else %}
<tr><td colspan="3" class="text-center text-muted py-5">No users yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Add / Edit modal -->
<div class="modal fade" id="userModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form id="userForm">
<div class="modal-header">
<h5 class="modal-title" id="userModalTitle">Edit User</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="userId">
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" class="form-control" id="userUsername" required>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" class="form-control" id="userPassword" autocomplete="new-password">
<div class="form-text" id="userPasswordHelp" hidden>Leave blank to keep the current password.</div>
</div>
<div class="mb-3">
<label class="form-label">Role</label>
<select class="form-select" id="userRole">
{% for option in role_options %}
<option value="{{ option }}">{{ option|capitalize }}</option>
{% endfor %}
</select>
</div>
<div id="userError" class="text-danger small"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const userModalEl = document.getElementById('userModal');
const userModal = new bootstrap.Modal(userModalEl);
function openUserModal(user) {
document.getElementById('userForm').reset();
document.getElementById('userError').textContent = '';
const isEdit = !!user;
document.getElementById('userPasswordHelp').hidden = !isEdit;
document.getElementById('userPassword').required = !isEdit;
if (isEdit) {
document.getElementById('userModalTitle').textContent = 'Edit User';
document.getElementById('userId').value = user.id;
document.getElementById('userUsername').value = user.username || '';
document.getElementById('userRole').value = user.role || 'viewer';
} else {
document.getElementById('userModalTitle').textContent = 'Add User';
document.getElementById('userId').value = '';
document.getElementById('userRole').value = 'viewer';
}
userModal.show();
}
document.getElementById('userForm').addEventListener('submit', async function (e) {
e.preventDefault();
const id = document.getElementById('userId').value;
const payload = {
username: document.getElementById('userUsername').value,
password: document.getElementById('userPassword').value,
role: document.getElementById('userRole').value,
};
const url = id ? `/api/users/${id}` : '/api/users';
const method = id ? 'PUT' : 'POST';
const resp = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (resp.ok) {
window.location.reload();
} else {
const data = await resp.json().catch(() => ({}));
document.getElementById('userError').textContent = data.error || 'Failed to save. Please check the fields.';
}
});
async function deleteUser(id, username) {
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
const resp = await fetch(`/api/users/${id}`, { method: 'DELETE' });
if (resp.ok) {
window.location.reload();
} else {
const data = await resp.json().catch(() => ({}));
alert(data.error || 'Failed to delete.');
}
}
</script>
{% endblock %}
+15
View File
@@ -0,0 +1,15 @@
services:
telcoportal:
build:
context: ./TelcoPortal-source
dockerfile: Dockerfile
container_name: telcoportal
restart: unless-stopped
ports:
- "8092:8000"
volumes:
# Host-side folder - keeps the SQLite database across rebuilds/updates, and makes it
# easy to back up (just copy/zip this folder).
- ./TelcoPortal-data:/app/data
environment:
- TELCO_DATA_DIR=/app/data