Compare commits

...

10 Commits

Author SHA1 Message Date
5f0130d0d1 God, I hope I'm ready 2024-08-21 05:24:07 +03:00
04a27b592e Pyinstaller support for assets 2024-08-21 04:05:36 +03:00
d6ecae08bd Switched to other domain 2024-08-16 18:40:42 +03:00
aa786de5b4 Cleanup 2024-08-11 14:39:00 +03:00
409f13b584 Sync widget 2024-08-11 10:24:55 +03:00
c101aa8aa6 File sync 2024-08-11 08:19:16 +03:00
4c01ca7510 Folder info and permissions 2024-08-10 08:59:11 +03:00
37940d633a Folder creation 2024-08-09 23:14:31 +03:00
b0f20680b4 User info widgets 2024-08-09 22:50:20 +03:00
cb3b5a3c27 File size fixes 2024-08-09 20:44:44 +03:00
15 changed files with 889 additions and 160 deletions

2
.gitignore vendored
View File

@ -159,3 +159,5 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear # and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
database.db

View File

@ -8,11 +8,7 @@ from state import State
if __name__ == "__main__": if __name__ == "__main__":
dotenv.load_dotenv() dotenv.load_dotenv()
url = os.environ.get("DRIVE_HOST_URL") url = os.environ.get("DRIVE_HOST_URL")
if not url:
url = "localhost:3000"
else:
url = url.strip()
app = QApplication(sys.argv) app = QApplication(sys.argv)
window = State(url) window = State()
window.show() window.show()
sys.exit(app.exec()) sys.exit(app.exec())

View File

@ -58,7 +58,7 @@ class RegisterWidget(QWidget):
try: try:
response = RequestClient().client.post( response = RequestClient().client.post(
"http://localhost:3000/users/register", "/users/register",
data={ data={
"username": username, "username": username,
"email": email, "email": email,
@ -79,11 +79,12 @@ class RegisterWidget(QWidget):
self, "Error", f"Registration failed: {response.text}" self, "Error", f"Registration failed: {response.text}"
) )
except httpx.HTTPError as e: except httpx.HTTPError as e:
print(e)
QMessageBox.critical(self, "HTTP Error", str(e)) QMessageBox.critical(self, "HTTP Error", str(e))
class LoginWidget(QWidget): class LoginWidget(QWidget):
def __init__(self, switcher: State): def __init__(self, switcher: state.State):
super().__init__() super().__init__()
self.switcher = switcher self.switcher = switcher
@ -116,12 +117,14 @@ class LoginWidget(QWidget):
password = self.password_input.text() password = self.password_input.text()
if not username or not password: if not username or not password:
QMessageBox.warning(self, "Input Error", "Email and Password are required") QMessageBox.warning(
self, "Input Error", "Email and Password are required"
)
return return
try: try:
response = RequestClient().client.post( response = RequestClient().client.post(
"http://localhost:3000/users/authorize", "/users/authorize",
data={"username": username, "password": password}, data={"username": username, "password": password},
) )
if response.is_success: if response.is_success:
@ -129,8 +132,12 @@ class LoginWidget(QWidget):
if access_token: if access_token:
self.switcher.login(access_token) self.switcher.login(access_token)
else: else:
QMessageBox.warning(self, "Error", "No access token received") QMessageBox.warning(
self, "Error", "No access token received"
)
else: else:
QMessageBox.warning(self, "Error", f"Login failed: {response.text}") QMessageBox.warning(
self, "Error", f"Login failed: {response.text}"
)
except httpx.HTTPError as e: except httpx.HTTPError as e:
QMessageBox.critical(self, "HTTP Error", str(e)) QMessageBox.critical(self, "HTTP Error", str(e))

View File

@ -0,0 +1,48 @@
from __future__ import annotations
import uuid
import file_widgets
from PyQt6.QtWidgets import (QLineEdit, QMessageBox, QPushButton, QVBoxLayout,
QWidget)
from request_client import RequestClient
class CreateFolderWidget(QWidget):
def __init__(
self, folder_id: uuid.UUID, file_list: file_widgets.FileListWidget
):
super().__init__()
self.folder_id = folder_id
self.file_list = file_list
self.setWindowTitle("Folder creation")
layout = QVBoxLayout()
self.edit = QLineEdit()
layout.addWidget(self.edit)
button = QPushButton("Submit")
button.clicked.connect(self.submit)
layout.addWidget(button)
self.setLayout(layout)
def submit(self):
try:
response = RequestClient().client.post(
"/folders",
json={
"folder_name": self.edit.text(),
"parent_folder_id": str(self.folder_id),
},
)
if not response.is_success:
QMessageBox.warning(
None, "Error creating folder", response.text
)
else:
QMessageBox.information(
None, "Folder created", "Folder created"
)
self.file_list.update_response()
self.close()
except Exception as e:
print(e)
QMessageBox.critical(self, "HTTP error", str(e))

143
desktop_client/file.py Normal file
View File

@ -0,0 +1,143 @@
from __future__ import annotations
import base64
import datetime
import hashlib
import os
import uuid
import dateutil
import dateutil.tz
import file_widgets
import pydantic
from PyQt6.QtGui import QIcon
from PyQt6.QtWidgets import QFileDialog, QLabel, QMessageBox, QWidget
from request_client import RequestClient
from utils import resource_path
class File(pydantic.BaseModel):
file_id: uuid.UUID
file_name: str
file_size: int
sha512: str
created_at: datetime.datetime
updated_at: datetime.datetime
def name(self) -> str:
return self.file_name
def delete(self) -> None:
RequestClient().client.delete(
"/files", params={"file_id": self.file_id}
)
def details(self, list: file_widgets.FileListWidget) -> QWidget:
del list
file_size = self._format_size(self.file_size)
file_size_text = f"{file_size[0]:.2f} {file_size[1]}"
created_at = self._format_date(self.created_at)
updated_at = self._format_date(self.updated_at)
details = (
f"file id: {self.file_id}\nfile_name: {self.file_name}\n"
+ f"file_size: {file_size_text}\n"
+ f"created at: {created_at}\nupdated at: {updated_at}"
)
label = QLabel()
label.setWindowTitle("File info")
label.setText(details)
return label
@staticmethod
def _format_size(size: int):
power = 2**10
n = 0
power_labels = {0: "", 1: "kibi", 2: "mebi", 3: "gibi", 4: "tebi"}
while size > power and n < 4:
size /= power
n += 1
return size, power_labels[n] + "bytes"
@staticmethod
def _format_date(date: datetime.datetime) -> str:
date = date.replace(tzinfo=dateutil.tz.tzutc())
date = date.astimezone(dateutil.tz.tzlocal())
return date.strftime("%Y-%m-%d %H:%M:%S")
def icon(self) -> QIcon:
return QIcon(resource_path("assets/file.png"))
def double_click(self, list: file_widgets.FileListWidget) -> None:
location = QFileDialog.getExistingDirectory(
list, caption="Select save location"
)
if not location:
return
with open(
os.path.join(location, self.file_name), "wb"
) as f, RequestClient().client.stream(
"GET", "/files", params={"file_id": self.file_id}
) as stream:
if not stream.is_success:
QMessageBox.warning(list, "Error downloading the file")
return
for data in stream.iter_bytes():
f.write(data)
def create(path: str, parent_id: uuid.UUID):
"""Upload the file"""
print(path)
file_name = os.path.basename(path)
try:
with open(path, "rb") as f:
files = {"file": (file_name, f)}
response = RequestClient().client.post(
"/files",
files=files,
params={"parent_folder": parent_id},
)
if not response.is_success:
QMessageBox.warning(
None, "Error", f"Upload failed: {response.text}"
)
print(response.text)
except Exception as e:
QMessageBox.critical(None, "HTTP Error", str(e))
def download(self, path: str):
try:
with open(path, "wb") as f, RequestClient().client.stream(
"GET", "/files", params={"file_id": self.file_id}
) as stream:
if not stream.is_success:
QMessageBox.warning(
None,
"Error downloading the file",
"Error downloading the file",
)
return
for data in stream.iter_bytes():
f.write(data)
except Exception as e:
QMessageBox.warning(None, "Error downloading the file", str(e))
def modify(self, path: str):
"""Upload the file"""
file_name = os.path.basename(path)
hash = hashlib.file_digest(open(path, "rb"), "sha512").digest()
if base64.b64decode(self.sha512) == hash:
return
try:
with open(path, "rb") as f:
files = {"file": (file_name, f)}
response = RequestClient().client.patch(
"/files",
files=files,
params={"file_id": self.file_id},
)
if not response.is_success:
QMessageBox.warning(
None, "Error", f"Upload failed: {response.text}"
)
except Exception as e:
QMessageBox.critical(None, "HTTP Error", str(e))

View File

@ -1,13 +1,17 @@
from __future__ import annotations from __future__ import annotations
import datetime import dataclasses
import os
import uuid import uuid
import threading
from typing import Protocol, Self from typing import Protocol, Self
import httpx import create_folder_widget
import pydantic import pydantic
import state import state
import sync
import user
from file import File
from folder import Folder
from PyQt6.QtCore import QPoint, Qt from PyQt6.QtCore import QPoint, Qt
from PyQt6.QtGui import ( from PyQt6.QtGui import (
QAction, QAction,
@ -21,12 +25,9 @@ from PyQt6.QtWidgets import (
QListWidget, QListWidget,
QListWidgetItem, QListWidgetItem,
QMenu, QMenu,
QMessageBox,
QPushButton, QPushButton,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
QLabel,
QFileDialog,
) )
from request_client import RequestClient from request_client import RequestClient
@ -48,91 +49,6 @@ class DisplayProtocol(Protocol):
raise NotImplementedError raise NotImplementedError
class File(pydantic.BaseModel):
file_id: uuid.UUID
file_name: str
file_size: int
sha512: str
created_at: datetime.datetime
updated_at: datetime.datetime
def name(self) -> str:
return self.file_name
def delete(self) -> None:
RequestClient().client.delete(
"/files", params={"file_id": self.file_id}
)
def details(self, list: FileListWidget) -> QWidget:
del list
details = (
f"file id: {self.file_id}\nfile_name: {self.file_name}\n"
+ f"file_size: {self._format_bytes(self.file_size)}\n"
+ f"created at: {self.created_at}\nupdated at: {self.updated_at}"
)
label = QLabel()
label.setWindowTitle("File info")
label.setText(details)
return label
@staticmethod
def _format_bytes(size: int):
power = 2**10
n = 0
power_labels = {0: "", 1: "kilo", 2: "mega", 3: "giga", 4: "tera"}
while size > power and n < 4:
size /= power
n += 1
return size, power_labels[n] + "bytes"
def icon(self) -> QIcon:
return QIcon("assets/file.png")
def double_click(self, list: FileListWidget) -> None:
location = QFileDialog.getExistingDirectory(
list, caption="Select save location"
)
if not location:
return
with open(
os.path.join(location, self.file_name), "wb"
) as f, RequestClient().client.stream(
"GET", "/files", params={"file_id": self.file_id}
) as stream:
if not stream.is_success:
QMessageBox.warning(list, "Error downloading the file")
return
for data in stream.iter_bytes():
f.write(data)
class Folder(pydantic.BaseModel):
folder_id: uuid.UUID
owner_id: int
folder_name: str
created_at: datetime.datetime
def name(self) -> str:
return self.folder_name
def delete(self) -> None:
RequestClient().client.delete(
"/folders", params={"folder_id": self.folder_id}
)
def details(self, list: FileListWidget) -> QWidget:
# TODO
raise NotImplementedError
def icon(self) -> QIcon:
return QIcon("assets/folder.png")
def double_click(self, list: FileListWidget) -> None:
list.responses.append(ListResponse.get(self.folder_id))
list.update()
class ResponseProtocol(Protocol): class ResponseProtocol(Protocol):
def items(self) -> list[DisplayProtocol]: def items(self) -> list[DisplayProtocol]:
raise NotImplementedError raise NotImplementedError
@ -140,6 +56,9 @@ class ResponseProtocol(Protocol):
def update(self) -> ResponseProtocol: def update(self) -> ResponseProtocol:
raise NotImplementedError raise NotImplementedError
def create_folder(self) -> QWidget | None:
raise NotImplementedError
class ListResponse(pydantic.BaseModel): class ListResponse(pydantic.BaseModel):
folder_id: uuid.UUID folder_id: uuid.UUID
@ -161,6 +80,34 @@ class ListResponse(pydantic.BaseModel):
def update(self) -> ResponseProtocol: def update(self) -> ResponseProtocol:
return self.get(self.folder_id) return self.get(self.folder_id)
def create_folder(self, file_list: FileListWidget):
return create_folder_widget.CreateFolderWidget(
self.folder_id, file_list
)
@dataclasses.dataclass(slots=True)
class TlpResponse:
folders: list[Folder]
@staticmethod
def get() -> Self:
url = "/permissions/get_top_level_permitted_folders"
return TlpResponse(
pydantic.TypeAdapter(list[Folder]).validate_json(
RequestClient().client.get(url).text
)
)
def items(self) -> list[DisplayProtocol]:
return self.folders
def update(self) -> ResponseProtocol:
return self.get()
def create_folder(self, _: FileListWidget):
return # Not much to do
class FileListWidget(QListWidget): class FileListWidget(QListWidget):
def __init__(self, state: state.State): def __init__(self, state: state.State):
@ -180,7 +127,7 @@ class FileListWidget(QListWidget):
item = self.current_response().items()[row] item = self.current_response().items()[row]
item.double_click(self) item.double_click(self)
def current_response(self) -> ListResponse: def current_response(self) -> ResponseProtocol:
if not self.responses: if not self.responses:
self.update_response() self.update_response()
return self.responses[-1] return self.responses[-1]
@ -200,34 +147,19 @@ class FileListWidget(QListWidget):
def dropEvent(self, event: QDropEvent): def dropEvent(self, event: QDropEvent):
event.accept() event.accept()
print("hi")
for url in event.mimeData().urls(): for url in event.mimeData().urls():
file_path = url.toLocalFile() file_path = url.toLocalFile()
self.upload_file(file_path) self.upload_file(file_path)
def upload_file(self, file_path): def upload_file(self, file_path):
file_name = os.path.basename(file_path) def _inner():
try: response = self.current_response()
with open(file_path, "rb") as f: if not isinstance(response, ListResponse):
files = {"file": (file_name, f)} return
response = RequestClient().client.post( File.create(file_path, response.folder_id)
"http://localhost:3000/files",
files=files,
params={
"parent_folder": self.current_response().folder_id
},
)
if response.is_success:
QMessageBox.information(
self, "Success", "File uploaded successfully"
)
self.update_response() self.update_response()
else:
QMessageBox.warning( threading.Thread(target=_inner).start()
self, "Error", f"Upload failed: {response.text}"
)
except httpx.HTTPError as e:
QMessageBox.critical(self, "HTTP Error", str(e))
def add_item(self, item: DisplayProtocol): def add_item(self, item: DisplayProtocol):
widget = QListWidgetItem(item.name()) widget = QListWidgetItem(item.name())
@ -257,7 +189,6 @@ class FileListWidget(QListWidget):
item = self.current_response().items()[row] item = self.current_response().items()[row]
item.delete() item.delete()
self.update_response() self.update_response()
QMessageBox.information(self, "Delete", f"{item.name()} deleted")
def update(self) -> None: def update(self) -> None:
self.clear() self.clear()
@ -271,26 +202,59 @@ class Sidebar(QWidget):
super().__init__() super().__init__()
self.state = state self.state = state
self.file_list = file_list self.file_list = file_list
self.user_widget = None
self.folder_widget = None
layout = QVBoxLayout() layout = QVBoxLayout()
self.setLayout(layout) self.setLayout(layout)
# Add your sidebar buttons here buttons = [
for i in range(5): # Example buttons ("User info", self.get_user),
btn = QPushButton(f"Button {i+1}") ("Log out", self.logout),
layout.addWidget(btn) ("Go back", self.go_back),
("Go root", self.go_root),
("Get permitted", self.get_tlp),
("Refresh", self.refresh),
("Create folder", self.create_folder),
("Sync all", self.sync_all),
]
for text, func in buttons:
button = QPushButton(text)
button.clicked.connect(func)
layout.addWidget(button)
layout.addStretch() layout.addStretch()
def get_user(self): ... def get_user(self):
self.user_widget = user.UserWidget(self.state)
self.user_widget.show()
def logout(self): ... def logout(self):
self.state.logout()
def go_back(self): ... def go_back(self):
if len(self.file_list.responses) >= 2:
self.file_list.responses.pop()
self.file_list.update()
def go_root(self): ... def go_root(self):
self.file_list.responses.append(ListResponse.get())
self.file_list.update()
def get_tlp(self): def get_tlp(self):
"""Get top level permitted folders""" """Get top level permitted folders"""
self.file_list.responses.append(TlpResponse.get())
self.file_list.update()
def sync(self): ... def refresh(self):
self.file_list.update_response()
def create_folder(self):
self.folder_widget = self.file_list.current_response().create_folder(
self.file_list
)
if self.folder_widget is not None:
self.folder_widget.show()
def sync_all(self):
threading.Thread(target=sync.SyncData.sync_all).start()
class MainFileWidget(QWidget): class MainFileWidget(QWidget):

66
desktop_client/folder.py Normal file
View File

@ -0,0 +1,66 @@
from __future__ import annotations
import datetime
import typing
import uuid
if typing.TYPE_CHECKING:
import file_widgets
import pydantic
from PyQt6.QtGui import QIcon
from PyQt6.QtWidgets import QMessageBox, QWidget
from request_client import RequestClient
from utils import resource_path
class Folder(pydantic.BaseModel):
folder_id: uuid.UUID
owner_id: int
folder_name: str
created_at: datetime.datetime
def name(self) -> str:
return self.folder_name
def delete(self) -> None:
import sync
sync.SyncData.delete(self.folder_id)
response = RequestClient().client.delete(
"/folders", params={"folder_id": self.folder_id}
)
if not response:
QMessageBox.warning(None, "Error deleting folder", response.text)
def details(self, _: file_widgets.FileListWidget) -> QWidget:
import folder_info
return folder_info.FolderInfoWidget(self)
def icon(self) -> QIcon:
return QIcon(resource_path("assets/folder.png"))
def double_click(self, list: file_widgets.FileListWidget) -> None:
import file_widgets
list.responses.append(file_widgets.ListResponse.get(self.folder_id))
list.update()
@staticmethod
def create(name: str, parent_id: uuid.UUID) -> uuid.UUID:
try:
response = RequestClient().client.post(
"/folders",
json={
"folder_name": name,
"parent_folder_id": str(parent_id),
},
)
if not response.is_success:
QMessageBox.warning(
None, "Error creating folder", response.text
)
return uuid.UUID(response.text.strip('"'))
except Exception as e:
QMessageBox.warning(None, "Error creating the folder", str(e))

View File

@ -0,0 +1,200 @@
from __future__ import annotations
import enum
import uuid
from dataclasses import dataclass
from functools import cache, partial
import file_widgets
import pydantic
import sync
import user
from PyQt6.QtWidgets import (
QComboBox,
QFileDialog,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QSpacerItem,
QVBoxLayout,
QWidget,
)
from request_client import RequestClient
class Permission(enum.StrEnum):
read = enum.auto()
write = enum.auto()
manage = enum.auto()
def set(self, folder_id: uuid.UUID, user_id: int):
try:
response = RequestClient().client.post(
"/permissions",
json={
"folder_id": str(folder_id),
"permission_type": str(self),
"user_id": int(user_id),
},
)
if not response.is_success:
QMessageBox.warning(
None, "Error setting permissions", response.text
)
except Exception as e:
QMessageBox.warning(None, "Error setting permissions", str(e))
@staticmethod
def delete(folder_id: uuid.UUID, user_id: int, widget: FolderInfoWidget):
try:
response = RequestClient().client.delete(
"/permissions",
params={
"folder_id": folder_id,
"user_id": user_id,
},
)
if not response.is_success:
QMessageBox.warning(
None, "Error deleting permissions", response.text
)
else:
widget.redraw()
except Exception as e:
QMessageBox.warning(None, "Error deleting permissions", str(e))
@dataclass(slots=True)
class Permissions:
mapping: dict[int, Permission]
@staticmethod
def get(folder_id: uuid.UUID) -> Permissions:
try:
mapping = pydantic.TypeAdapter(
dict[str, Permission]
).validate_json(
RequestClient()
.client.get("/permissions", params={"folder_id": folder_id})
.text
)
return Permissions(mapping)
except Exception as e:
QMessageBox.warning(None, "Error getting permissions", str(e))
return Permissions({})
class FolderInfoWidget(QWidget):
def __init__(self, folder: file_widgets.Folder):
super().__init__()
self.setWindowTitle("Folder info")
self.folder = folder
self.user_cache = cache(user.User.get)
self.user_mapping: dict[str, int] = {}
self.redraw()
def redraw(self):
self.permissions = Permissions.get(self.folder.folder_id)
main_layout = QVBoxLayout()
owner_name = self.user_cache(self.folder.owner_id)
main_layout.addWidget(
QLabel(
f"Owner: {owner_name}\n"
+ f"Folder name: {self.folder.folder_name}\n"
+ f"Created at: {self.folder.created_at}"
)
)
for user_id, permissions in self.permissions.mapping.items():
layout = QHBoxLayout()
name = QLabel(self.user_cache(user_id).username)
combo = QComboBox()
combo.addItems(map(str, Permission))
combo.setCurrentText(str(permissions))
combo.currentTextChanged.connect(
partial(self.change, user_id=user_id)
)
delete = QPushButton("Delete")
delete.clicked.connect(
partial(
Permission.delete, self.folder.folder_id, user_id, self
)
)
layout.addWidget(name)
layout.addWidget(combo)
layout.addWidget(delete)
main_layout.addLayout(layout)
layout = QHBoxLayout()
self.search_str = QLineEdit()
layout.addWidget(self.search_str)
search = QPushButton("Search")
search.clicked.connect(self.search)
layout.addWidget(search)
main_layout.addLayout(layout)
layout = QHBoxLayout()
self.search_combo = QComboBox()
self.perm_combo = QComboBox()
self.perm_combo.addItems(map(str, Permission))
button = QPushButton("+")
button.clicked.connect(self.search_save)
layout.addWidget(self.search_combo)
layout.addWidget(self.perm_combo)
layout.addWidget(button)
main_layout.addLayout(layout)
main_layout.addSpacerItem(QSpacerItem(0, 50))
synced_to = sync.SyncData.get_for_folder(self.folder.folder_id)
if synced_to is not None:
synced_to_text = f"Synched to {synced_to.path}"
else:
synced_to_text = "Not synced"
layout = QHBoxLayout()
label = QLabel(synced_to_text)
layout.addWidget(label)
if synced_to is not None:
button = QPushButton("Delete")
button.clicked.connect(self.remove_sync)
else:
button = QPushButton("Add sync")
button.clicked.connect(self.add_sync)
layout.addWidget(button)
main_layout.addLayout(layout)
if self.layout():
QWidget().setLayout(self.layout())
self.setLayout(main_layout)
def change(self, text: str, *, user_id: int):
Permission(text).set(self.folder.folder_id, user_id)
def search(self):
result = user.UserSearch.search(self.search_str.text())
self.search_combo.clear()
self.user_mapping.clear()
for item in result:
self.user_mapping[item.username] = item.user_id
self.search_combo.addItem(item.username)
def search_save(self):
permission = Permission(self.perm_combo.currentText())
search = self.search_combo.currentText()
if not search:
return
user_id = self.user_mapping[self.search_combo.currentText()]
permission.set(self.folder.folder_id, user_id)
self.redraw()
def add_sync(self):
path = QFileDialog.getExistingDirectory()
if path:
sync.SyncData.new_sync(path, self.folder.folder_id)
self.redraw()
def remove_sync(self):
sync.SyncData.delete(self.folder.folder_id)
self.redraw()

View File

@ -9,9 +9,9 @@ class RequestClient:
def __new__(cls) -> Self: def __new__(cls) -> Self:
if cls._client is None: if cls._client is None:
url = os.environ.get("DRIVE_HOST_URL").strip() url = os.environ.get("DRIVE_HOST_URL")
if not url: if url is None:
url = "localhost:3000" url = "https://drive.stnicolay.ru"
cls._client = httpx.Client(base_url=url) cls._client = httpx.Client(base_url=url)
return super().__new__(cls) return super().__new__(cls)
@ -19,6 +19,10 @@ class RequestClient:
def set_token(cls, token: str): def set_token(cls, token: str):
cls._client.headers = {"Authorization": f"Bearer {token}"} cls._client.headers = {"Authorization": f"Bearer {token}"}
@classmethod
def delete_token(cls):
cls._client.headers = {}
@property @property
def client(self) -> httpx.Client: def client(self) -> httpx.Client:
return self._client return self._client

View File

@ -1,19 +1,18 @@
import auth import auth
import file_widgets import file_widgets
import httpx
import keyring import keyring
import request_client import request_client
from PyQt6.QtWidgets import QMainWindow, QStackedWidget import sync
from PyQt6.QtWidgets import QMainWindow, QMessageBox, QStackedWidget
class State(QMainWindow): class State(QMainWindow):
def __init__(self, url: str): def __init__(self):
super().__init__() super().__init__()
self.setWindowTitle("Auth App") self.setWindowTitle("Drive application")
self.stack = QStackedWidget() self.stack = QStackedWidget()
self.client = httpx.Client(base_url=url)
self.register_widget = auth.RegisterWidget(self) self.register_widget = auth.RegisterWidget(self)
self.login_widget = auth.LoginWidget(self) self.login_widget = auth.LoginWidget(self)
@ -27,7 +26,7 @@ class State(QMainWindow):
password = keyring.get_credential("auth_app", "access_token") password = keyring.get_credential("auth_app", "access_token")
if password is None: if password is None:
self.switch_to_login() # Start with the login widget self.switch_to_login() # Start with the login widget
else:
self.login(password.password) self.login(password.password)
def switch_to_login(self): def switch_to_login(self):
@ -37,8 +36,21 @@ class State(QMainWindow):
self.stack.setCurrentWidget(self.register_widget) self.stack.setCurrentWidget(self.register_widget)
def login(self, token: str): def login(self, token: str):
try:
keyring.set_password("auth_app", "access_token", token) keyring.set_password("auth_app", "access_token", token)
request_client.RequestClient().set_token(token) request_client.RequestClient().set_token(token)
self.file_widget = file_widgets.MainFileWidget(self) self.file_widget = file_widgets.MainFileWidget(self)
self.stack.addWidget(self.file_widget) self.stack.addWidget(self.file_widget)
self.stack.setCurrentWidget(self.file_widget) self.stack.setCurrentWidget(self.file_widget)
except Exception as e:
print(e)
keyring.delete_password("auth_app", "access_token")
QMessageBox.information(
None, "Error logging in", "Error logging in"
)
def logout(self):
keyring.delete_password("auth_app", "access_token")
request_client.RequestClient().delete_token()
sync.SyncData.delete_all()
self.switch_to_login()

148
desktop_client/sync.py Normal file
View File

@ -0,0 +1,148 @@
from __future__ import annotations
import datetime
import os
import uuid
from concurrent.futures import ProcessPoolExecutor
from time import ctime
from file import File
from folder import Folder
from request_client import RequestClient
from sqlmodel import Field, Session, SQLModel, create_engine, delete, select
import multiprocessing
from PyQt6.QtWidgets import QMessageBox
class FolderStructure(Folder):
files: list[File]
folders: list[FolderStructure]
def find_folder(self, name: str) -> File | None:
for file in self.folders:
if file.folder_name == name:
return file
def find_file(self, name: str) -> File | None:
for file in self.files:
if file.file_name == name:
return file
@staticmethod
def get_structure(folder_id: uuid.UUID) -> FolderStructure:
return FolderStructure.model_validate_json(
RequestClient()
.client.get("/folders/structure", params={"folder_id": folder_id})
.text
)
class SyncData(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
folder_id: uuid.UUID = Field(unique=True, index=True)
path: str
last_updated: datetime.datetime
def sync_one(self):
try:
merge_folder(
self.path,
FolderStructure.get_structure(self.folder_id),
self.last_updated,
)
except Exception as e:
SyncData.delete(self.folder_id)
QMessageBox.warning(
None,
"Error syncing folder",
f"Error syncing {self.path!r} folder:\n{e}",
)
@staticmethod
def sync_all():
with Session(engine) as s:
syncs = s.exec(select(SyncData)).fetchall()
with ProcessPoolExecutor(3) as pool:
tuple(pool.map(SyncData.sync_one, syncs))
@staticmethod
def new_sync(path: str, folder_id: uuid.UUID):
with Session(engine) as s:
sync = SyncData(
path=path,
last_updated=datetime.datetime.now(),
folder_id=folder_id,
)
s.add(sync)
s.commit()
multiprocessing.Process(
target=lambda: upload_folder(path, folder_id)
).start()
@staticmethod
def get_for_folder(folder_id: uuid.UUID) -> SyncData | None:
with Session(engine) as s:
return s.exec(
select(SyncData).where(SyncData.folder_id == folder_id)
).one_or_none()
@staticmethod
def delete(folder_id: uuid.UUID):
with Session(engine) as s:
s.exec(delete(SyncData).where(SyncData.folder_id == folder_id))
s.commit()
@staticmethod
def delete_all():
with Session(engine) as s:
s.exec(delete(SyncData))
s.commit()
sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"
engine = create_engine(sqlite_url, echo=True)
SQLModel.metadata.create_all(engine)
def upload_folder(path: str, folder_id: uuid.UUID):
items = os.listdir(path)
for item in items:
item_path = os.path.join(path, item)
if os.path.isfile(item_path):
File.create(item_path, folder_id)
elif os.path.isdir(item_path):
id = Folder.create(item, folder_id)
upload_folder(item_path, id)
def merge_folder(
path: str,
structure: FolderStructure,
time: datetime.datetime,
):
for item in os.listdir(path):
item_path = os.path.join(path, item)
if os.path.isfile(item_path):
file = structure.find_file(item)
if file is None:
File.create(item_path, structure.folder_id)
continue
mtime = datetime.datetime.strptime(
ctime(os.path.getmtime(item_path)), "%a %b %d %H:%M:%S %Y"
)
print(item, mtime, time)
if mtime > time:
file.modify(item_path)
else:
file.download(item_path)
elif os.path.isdir(item_path):
folder = structure.find_folder(item)
if folder is None:
folder_id = Folder.create(item, structure.folder_id)
upload_folder(item_path, folder_id)
else:
merge_folder(item_path, folder, time)

101
desktop_client/user.py Normal file
View File

@ -0,0 +1,101 @@
from __future__ import annotations
import pydantic
import state
from PyQt6.QtWidgets import (
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QVBoxLayout,
QWidget,
)
from request_client import RequestClient
class User(pydantic.BaseModel):
user_id: int | None = None
username: str
email: str
@staticmethod
def get(user_id: int | None = None) -> User:
params = {}
if user_id:
url = "/users"
params["user_id"] = user_id
else:
url = "/users/current"
response = RequestClient().client.get(url, params=params)
if not response.is_success:
QMessageBox.warning(
None, "Error getting permissions", response.text
)
return
return User.model_validate_json(response.text)
@staticmethod
def delete():
if not RequestClient().client.delete("/users").is_success:
raise Exception("Error deleting user")
def put(self):
response = RequestClient().client.put(
"/users", json={"username": self.username, "email": self.email}
)
if not response.is_success:
QMessageBox.warning(None, "Error updating user", response.text)
return
QMessageBox.information(None, "User updated", "User updated")
class UserSearch(User):
similarity: float
@staticmethod
def search(search_str: str) -> list[UserSearch]:
return pydantic.TypeAdapter(list[UserSearch]).validate_json(
RequestClient()
.client.get("/users/search", params={"search_string": search_str})
.text
)
class UserWidget(QWidget):
def __init__(self, state: state.State):
super().__init__()
self.state = state
self.setWindowTitle("User information")
self.user = User.get()
main_layout = QVBoxLayout()
lines = [("username", self.user.username), ("email", self.user.email)]
edits = []
for line in lines:
layout = QHBoxLayout()
label = QLabel(line[0])
edit = QLineEdit(line[1])
layout.addWidget(label)
layout.addWidget(edit)
main_layout.addLayout(layout)
edits.append(edit)
self.username: QLineEdit = edits[0]
self.email: QLineEdit = edits[1]
button_layout = QHBoxLayout()
buttons = [("Update", self.update_user), ("Delete", self.delete_user)]
for text, func in buttons:
button = QPushButton(text)
button.clicked.connect(func)
button_layout.addWidget(button)
main_layout.addLayout(button_layout)
self.setLayout(main_layout)
def delete_user(self):
User.delete()
self.close()
self.state.logout()
def update_user(self):
self.user.username = self.username.text()
self.user.email = self.email.text()
self.user.put()

12
desktop_client/utils.py Normal file
View File

@ -0,0 +1,12 @@
import os
import sys
from functools import cache
_DEFAULT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@cache
def resource_path(relative_path: str) -> str:
"""Get absolute path to resource, works for dev and for PyInstaller"""
base_path = getattr(sys, "_MEIPASS", _DEFAULT)
return os.path.join(base_path, relative_path)

43
poetry.lock generated
View File

@ -427,18 +427,18 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-ena
[[package]] [[package]]
name = "jaraco-context" name = "jaraco-context"
version = "5.3.0" version = "6.0.1"
description = "Useful decorators and context managers" description = "Useful decorators and context managers"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "jaraco.context-5.3.0-py3-none-any.whl", hash = "sha256:3e16388f7da43d384a1a7cd3452e72e14732ac9fe459678773a3608a812bf266"}, {file = "jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4"},
{file = "jaraco.context-5.3.0.tar.gz", hash = "sha256:c2f67165ce1f9be20f32f650f25d8edfc1646a8aeee48ae06fb35f90763576d2"}, {file = "jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3"},
] ]
[package.extras] [package.extras]
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
testing = ["portend", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"] test = ["portend", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"]
[[package]] [[package]]
name = "jaraco-functools" name = "jaraco-functools"
@ -788,6 +788,20 @@ files = [
{file = "PyQt6_sip-13.8.0.tar.gz", hash = "sha256:2f74cf3d6d9cab5152bd9f49d570b2dfb87553ebb5c4919abfde27f5b9fd69d4"}, {file = "PyQt6_sip-13.8.0.tar.gz", hash = "sha256:2f74cf3d6d9cab5152bd9f49d570b2dfb87553ebb5c4919abfde27f5b9fd69d4"},
] ]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
description = "Extensions to the standard Python datetime module"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
files = [
{file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
{file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
]
[package.dependencies]
six = ">=1.5"
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.0.1" version = "1.0.1"
@ -804,13 +818,13 @@ cli = ["click (>=5.0)"]
[[package]] [[package]]
name = "pywin32-ctypes" name = "pywin32-ctypes"
version = "0.2.2" version = "0.2.3"
description = "A (partial) reimplementation of pywin32 using ctypes/cffi" description = "A (partial) reimplementation of pywin32 using ctypes/cffi"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
files = [ files = [
{file = "pywin32-ctypes-0.2.2.tar.gz", hash = "sha256:3426e063bdd5fd4df74a14fa3cf80a0b42845a87e1d1e81f6549f9daec593a60"}, {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"},
{file = "pywin32_ctypes-0.2.2-py3-none-any.whl", hash = "sha256:bf490a1a709baf35d688fe0ecf980ed4de11d2b3e37b51e5442587a75d9957e7"}, {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"},
] ]
[[package]] [[package]]
@ -828,6 +842,17 @@ files = [
cryptography = ">=2.0" cryptography = ">=2.0"
jeepney = ">=0.6" jeepney = ">=0.6"
[[package]]
name = "six"
version = "1.16.0"
description = "Python 2 and 3 compatibility utilities"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
files = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
]
[[package]] [[package]]
name = "sniffio" name = "sniffio"
version = "1.3.1" version = "1.3.1"
@ -955,4 +980,4 @@ files = [
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.12" python-versions = "^3.12"
content-hash = "6e84cef360a53faa7bba2d9bb57f01093ff0291c28c02aff4727b6c21fd6d715" content-hash = "fd70e86969d9632a562a6755e8ff2f7e9d38e795da2f3c5c04eabe77d5723fdb"

View File

@ -13,6 +13,7 @@ pyqt6 = "^6.7.1"
keyring = "^25.3.0" keyring = "^25.3.0"
python-dotenv = "^1.0.1" python-dotenv = "^1.0.1"
sqlmodel = "^0.0.21" sqlmodel = "^0.0.21"
python-dateutil = "^2.9.0.post0"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]