Skip to content

Commit 0fe57f3

Browse files
committed
add database and fix all problem remains
1 parent 7229dcf commit 0fe57f3

File tree

4 files changed

+446
-202
lines changed

4 files changed

+446
-202
lines changed

Assignment1/client_ui.py

Lines changed: 185 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -460,50 +460,158 @@ def _handle_peer_list(self, fname, peer_list):
460460
self.fetch_button.config(state=tk.NORMAL)
461461
return
462462

463-
chosen_peer = peer_list[0]
464-
if len(peer_list) > 1:
465-
peer_lines = [
466-
f"{idx + 1}. {peer.get('hostname')} ({peer.get('ip')}:{peer.get('port')})"
467-
for idx, peer in enumerate(peer_list)
468-
]
469-
prompt = "Select a peer to download from:\n" + "\n".join(peer_lines)
470-
choice = simpledialog.askinteger(
471-
"Choose Peer",
472-
prompt,
473-
parent=self.root,
474-
minvalue=1,
475-
maxvalue=len(peer_list),
463+
selected_indices = self._show_peer_selection(fname, peer_list)
464+
if selected_indices is None:
465+
logging.info("Fetch cancelled; no peer selected.")
466+
self.fetch_button.config(state=tk.NORMAL)
467+
return
468+
469+
if len(selected_indices) == 1:
470+
chosen_peer = peer_list[selected_indices[0]]
471+
default_name = self._get_preferred_filename(chosen_peer, fname)
472+
save_path = filedialog.asksaveasfilename(
473+
title="Save Downloaded File",
474+
initialfile=default_name,
475+
defaultextension=os.path.splitext(default_name)[1],
476476
)
477-
if choice is None:
478-
logging.info("Fetch cancelled; no peer selected.")
477+
if not save_path:
478+
logging.info("Fetch cancelled; no destination selected.")
479479
self.fetch_button.config(state=tk.NORMAL)
480480
return
481-
chosen_peer = peer_list[choice - 1]
482481

483-
default_name = fname
484-
save_path = filedialog.asksaveasfilename(
485-
title="Save Downloaded File",
486-
initialfile=default_name,
487-
defaultextension=os.path.splitext(default_name)[1],
482+
if os.path.exists(save_path):
483+
overwrite = messagebox.askyesno("Overwrite?", f"File '{save_path}' exists. Overwrite?")
484+
if not overwrite:
485+
logging.info("Fetch cancelled; user chose not to overwrite %s.", save_path)
486+
self.fetch_button.config(state=tk.NORMAL)
487+
return
488+
489+
threading.Thread(
490+
target=self._download_task,
491+
args=(chosen_peer, save_path),
492+
daemon=True,
493+
).start()
494+
return
495+
496+
target_directory = filedialog.askdirectory(
497+
title="Select Destination Directory for Downloads",
498+
mustexist=True,
488499
)
489-
if not save_path:
490-
logging.info("Fetch cancelled; no destination selected.")
500+
if not target_directory:
501+
logging.info("Fetch cancelled; no destination directory selected.")
491502
self.fetch_button.config(state=tk.NORMAL)
492503
return
493504

494-
if os.path.exists(save_path):
495-
overwrite = messagebox.askyesno("Overwrite?", f"File '{save_path}' exists. Overwrite?")
496-
if not overwrite:
497-
logging.info("Fetch cancelled; user chose not to overwrite %s.", save_path)
498-
self.fetch_button.config(state=tk.NORMAL)
499-
return
505+
download_tasks = []
506+
for index in selected_indices:
507+
peer = peer_list[index]
508+
filename = self._get_preferred_filename(peer, fname)
509+
destination = self._unique_destination_path(target_directory, filename)
510+
download_tasks.append((peer, destination))
500511

512+
logging.info("Starting batch download for %d peer(s).", len(download_tasks))
501513
threading.Thread(
502-
target=self._download_task,
503-
args=(chosen_peer, save_path),
514+
target=self._download_multiple_task,
515+
args=(download_tasks,),
504516
daemon=True,
505517
).start()
506518

519+
def _show_peer_selection(self, fname, peer_list):
520+
if len(peer_list) == 1:
521+
return [0]
522+
523+
dialog = tk.Toplevel(self.root)
524+
dialog.title("Select Peer(s)")
525+
dialog.transient(self.root)
526+
dialog.grab_set()
527+
528+
instruction = tk.Label(
529+
dialog,
530+
text="Choose peer(s) to download from.\n"
531+
"Use Ctrl/Shift for multi-select or pick an option below.",
532+
justify=tk.LEFT,
533+
)
534+
instruction.pack(padx=10, pady=(10, 5), anchor="w")
535+
536+
listbox = tk.Listbox(dialog, selectmode=tk.MULTIPLE, width=60, height=min(10, len(peer_list)))
537+
listbox.pack(padx=10, pady=5, fill=tk.BOTH, expand=True)
538+
539+
for idx, peer in enumerate(peer_list, start=1):
540+
hostname = peer.get("hostname") or "Unknown"
541+
original_name = os.path.basename(peer.get("lname") or fname)
542+
listbox.insert(
543+
tk.END,
544+
f"{idx}. {hostname} ({original_name})",
545+
)
546+
547+
button_frame = tk.Frame(dialog)
548+
button_frame.pack(padx=10, pady=(5, 10), fill=tk.X)
549+
button_frame.columnconfigure((0, 1, 2, 3), weight=1)
550+
551+
result = {"indices": None}
552+
553+
def on_select():
554+
selection = listbox.curselection()
555+
if not selection:
556+
messagebox.showinfo("Selection required", "Select at least one peer.", parent=dialog)
557+
return
558+
result["indices"] = list(selection)
559+
dialog.destroy()
560+
561+
def on_select_all():
562+
result["indices"] = list(range(len(peer_list)))
563+
dialog.destroy()
564+
565+
def on_custom():
566+
raw = simpledialog.askstring(
567+
"Custom selection",
568+
"Enter peer numbers separated by commas (e.g. 1,3,4):",
569+
parent=dialog,
570+
)
571+
if raw is None:
572+
return
573+
try:
574+
indices = []
575+
for chunk in raw.replace(" ", "").split(","):
576+
if not chunk:
577+
continue
578+
value = int(chunk)
579+
if value < 1 or value > len(peer_list):
580+
raise ValueError(f"Peer number {value} is out of range.")
581+
zero_based = value - 1
582+
if zero_based not in indices:
583+
indices.append(zero_based)
584+
except ValueError as exc:
585+
messagebox.showerror("Invalid input", str(exc), parent=dialog)
586+
return
587+
if not indices:
588+
messagebox.showinfo("Selection required", "Provide at least one valid peer number.", parent=dialog)
589+
return
590+
result["indices"] = indices
591+
dialog.destroy()
592+
593+
def on_cancel():
594+
result["indices"] = None
595+
dialog.destroy()
596+
597+
select_button = tk.Button(button_frame, text="Download Selected", command=on_select, bg=PASTEL_BUTTON)
598+
select_button.grid(row=0, column=0, padx=5, sticky="ew")
599+
600+
all_button = tk.Button(button_frame, text="Download All", command=on_select_all, bg=PASTEL_BUTTON)
601+
all_button.grid(row=0, column=1, padx=5, sticky="ew")
602+
603+
custom_button = tk.Button(button_frame, text="Custom...", command=on_custom, bg=PASTEL_BUTTON)
604+
custom_button.grid(row=0, column=2, padx=5, sticky="ew")
605+
606+
cancel_button = tk.Button(button_frame, text="Cancel", command=on_cancel, bg=PASTEL_BUTTON)
607+
cancel_button.grid(row=0, column=3, padx=5, sticky="ew")
608+
609+
dialog.protocol("WM_DELETE_WINDOW", on_cancel)
610+
dialog.resizable(False, False)
611+
dialog.focus_set()
612+
self.root.wait_window(dialog)
613+
return result["indices"]
614+
507615
def _download_task(self, peer_info, save_path):
508616
try:
509617
self.controller.download_from_peer(peer_info, save_path)
@@ -529,6 +637,52 @@ def _on_download_finished(self, success, error_message, peer_info, save_path):
529637
messagebox.showerror("Download error", error_message)
530638
self.fetch_button.config(state=tk.NORMAL)
531639

640+
def _download_multiple_task(self, download_tasks):
641+
successes = []
642+
failures = []
643+
for peer_info, save_path in download_tasks:
644+
try:
645+
self.controller.download_from_peer(peer_info, save_path)
646+
except Exception as exc:
647+
logging.error("Download failed for %s: %s", save_path, exc)
648+
failures.append((peer_info, save_path, str(exc)))
649+
else:
650+
successes.append((peer_info, save_path))
651+
self.root.after(
652+
0,
653+
lambda: self._on_multi_download_finished(successes, failures),
654+
)
655+
656+
def _on_multi_download_finished(self, successes, failures):
657+
messages = []
658+
if successes:
659+
success_lines = "\n".join(f"- {path}" for _, path in successes)
660+
messages.append(f"Downloaded {len(successes)} file(s):\n{success_lines}")
661+
if failures:
662+
failure_lines = "\n".join(
663+
f"- {os.path.basename(path)} ({err})" for _, path, err in failures
664+
)
665+
messages.append(f"Failed downloads:\n{failure_lines}")
666+
667+
summary = "\n\n".join(messages) if messages else "No downloads were completed."
668+
messagebox.showinfo("Fetch summary", summary)
669+
self.fetch_button.config(state=tk.NORMAL)
670+
671+
def _get_preferred_filename(self, peer_info, fallback_name):
672+
original = peer_info.get("lname")
673+
if original:
674+
return os.path.basename(original)
675+
return os.path.basename(fallback_name)
676+
677+
def _unique_destination_path(self, directory, filename):
678+
base, ext = os.path.splitext(filename)
679+
candidate = os.path.join(directory, filename)
680+
counter = 1
681+
while os.path.exists(candidate):
682+
candidate = os.path.join(directory, f"{base}_{counter}{ext}")
683+
counter += 1
684+
return candidate
685+
532686
def clear_log(self):
533687
self.log_text.configure(state=tk.NORMAL)
534688
self.log_text.delete(1.0, tk.END)

Assignment1/database.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""PostgreSQL data access layer for the P2P metadata server."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import Dict, List, Optional
7+
8+
import psycopg2
9+
from psycopg2.extras import RealDictCursor
10+
11+
12+
DEFAULT_DB_CONFIG: Dict[str, object] = {
13+
"dbname": "p2p_metadata",
14+
"user": "p2p_server",
15+
"password": "p2p_pass",
16+
"host": "127.0.0.1",
17+
"port": 5432,
18+
}
19+
DEFAULT_DB_URL = (
20+
"postgresql://{user}:{password}@{host}:{port}/{dbname}".format(**DEFAULT_DB_CONFIG)
21+
)
22+
23+
24+
class Database:
25+
"""Thin helper that executes SQL statements using psycopg2.connect."""
26+
27+
def __init__(self, dsn: Optional[str] = None, **config):
28+
if dsn:
29+
self._conn_kwargs: Dict[str, object] = {"dsn": dsn}
30+
else:
31+
merged = DEFAULT_DB_CONFIG.copy()
32+
merged.update(config)
33+
self._conn_kwargs = merged
34+
self._ensure_schema()
35+
36+
def _connect(self):
37+
if "dsn" in self._conn_kwargs:
38+
logging.debug("Opening PostgreSQL connection with DSN.")
39+
return psycopg2.connect(self._conn_kwargs["dsn"]) # type: ignore[arg-type]
40+
logging.debug("Opening PostgreSQL connection with params: %s", self._conn_kwargs)
41+
return psycopg2.connect(**self._conn_kwargs)
42+
43+
def _ensure_schema(self) -> None:
44+
"""Ensure required tables and indexes exist."""
45+
create_table_stmt = """
46+
CREATE TABLE IF NOT EXISTS file_index (
47+
id SERIAL PRIMARY KEY,
48+
fname TEXT NOT NULL,
49+
hostname TEXT NOT NULL,
50+
ip TEXT NOT NULL,
51+
port INTEGER NOT NULL,
52+
lname TEXT,
53+
file_size BIGINT,
54+
last_modified TEXT
55+
);
56+
"""
57+
create_index_stmt = """
58+
CREATE UNIQUE INDEX IF NOT EXISTS uq_file_index_unique_peer
59+
ON file_index (fname, hostname, ip, port, file_size, last_modified);
60+
"""
61+
with self._connect() as conn, conn.cursor() as cur:
62+
cur.execute(create_table_stmt)
63+
cur.execute(create_index_stmt)
64+
logging.info("Database schema verified.")
65+
66+
def fetch_all_entries(self) -> List[Dict[str, object]]:
67+
query = """
68+
SELECT fname, hostname, ip, port, lname, file_size, last_modified
69+
FROM file_index
70+
"""
71+
with self._connect() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
72+
cur.execute(query)
73+
rows = cur.fetchall()
74+
return list(rows)
75+
76+
def list_peers_for_file(self, fname: str) -> List[Dict[str, object]]:
77+
query = """
78+
SELECT fname, hostname, ip, port, lname, file_size, last_modified
79+
FROM file_index
80+
WHERE fname = %s
81+
ORDER BY hostname, ip, port
82+
"""
83+
with self._connect() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
84+
cur.execute(query, (fname,))
85+
rows = cur.fetchall()
86+
return list(rows)
87+
88+
def register_file(self, entry: Dict[str, object]) -> bool:
89+
insert_stmt = """
90+
INSERT INTO file_index (fname, hostname, ip, port, lname, file_size, last_modified)
91+
VALUES (%(fname)s, %(hostname)s, %(ip)s, %(port)s, %(lname)s, %(file_size)s, %(last_modified)s)
92+
ON CONFLICT (fname, hostname, ip, port, file_size, last_modified)
93+
DO NOTHING
94+
RETURNING id
95+
"""
96+
with self._connect() as conn, conn.cursor() as cur:
97+
cur.execute(insert_stmt, entry)
98+
inserted = cur.fetchone()
99+
return inserted is not None
100+
101+
def delete_entries_for_peer(self, hostname: str, ip: str, port: int) -> Dict[str, int]:
102+
delete_stmt = """
103+
DELETE FROM file_index
104+
WHERE hostname = %s AND ip = %s AND port = %s
105+
RETURNING fname
106+
"""
107+
removed: Dict[str, int] = {}
108+
with self._connect() as conn, conn.cursor() as cur:
109+
cur.execute(delete_stmt, (hostname, ip, port))
110+
for fname, in cur.fetchall():
111+
removed[fname] = removed.get(fname, 0) + 1
112+
return removed
113+
114+
def list_files_by_hostname(self, hostname: str) -> List[str]:
115+
query = """
116+
SELECT DISTINCT fname
117+
FROM file_index
118+
WHERE hostname = %s
119+
ORDER BY fname
120+
"""
121+
with self._connect() as conn, conn.cursor() as cur:
122+
cur.execute(query, (hostname,))
123+
rows = cur.fetchall()
124+
return [row[0] for row in rows]
125+
126+
def close(self) -> None:
127+
# Each operation opens its own connection, so nothing to close.
128+
logging.info("Database helper shutdown complete.")

0 commit comments

Comments
 (0)