Compare commits

...

7 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
15 changed files with 704 additions and 174 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

@ -3,12 +3,15 @@ from __future__ import annotations
import uuid import uuid
import file_widgets import file_widgets
from PyQt6.QtWidgets import QLineEdit, QMessageBox, QPushButton, QVBoxLayout, QWidget from PyQt6.QtWidgets import (QLineEdit, QMessageBox, QPushButton, QVBoxLayout,
QWidget)
from request_client import RequestClient from request_client import RequestClient
class CreateFolderWidget(QWidget): class CreateFolderWidget(QWidget):
def __init__(self, folder_id: uuid.UUID, file_list: file_widgets.FileListWidget): def __init__(
self, folder_id: uuid.UUID, file_list: file_widgets.FileListWidget
):
super().__init__() super().__init__()
self.folder_id = folder_id self.folder_id = folder_id
self.file_list = file_list self.file_list = file_list
@ -22,6 +25,7 @@ class CreateFolderWidget(QWidget):
self.setLayout(layout) self.setLayout(layout)
def submit(self): def submit(self):
try:
response = RequestClient().client.post( response = RequestClient().client.post(
"/folders", "/folders",
json={ json={
@ -30,8 +34,15 @@ class CreateFolderWidget(QWidget):
}, },
) )
if not response.is_success: if not response.is_success:
QMessageBox.warning(None, "Error creating folder", response.text) QMessageBox.warning(
None, "Error creating folder", response.text
)
else: else:
QMessageBox.information(None, "Folder created", "Folder created") QMessageBox.information(
None, "Folder created", "Folder created"
)
self.file_list.update_response() self.file_list.update_response()
self.close() 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,26 +1,30 @@
from __future__ import annotations from __future__ import annotations
import dataclasses import dataclasses
import datetime
import os
import uuid import uuid
import threading
from typing import Protocol, Self from typing import Protocol, Self
import create_folder_widget import create_folder_widget
import httpx
import pydantic import pydantic
import state import state
import sync
import user 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 QAction, QDragEnterEvent, QDragMoveEvent, QDropEvent, QIcon from PyQt6.QtGui import (
QAction,
QDragEnterEvent,
QDragMoveEvent,
QDropEvent,
QIcon,
)
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QFileDialog,
QHBoxLayout, QHBoxLayout,
QLabel,
QListWidget, QListWidget,
QListWidgetItem, QListWidgetItem,
QMenu, QMenu,
QMessageBox,
QPushButton, QPushButton,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
@ -45,93 +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
file_size = self._format_bytes(self.file_size)
file_size_text = f"{file_size[0]:.2f} {file_size[1]}"
details = (
f"file id: {self.file_id}\nfile_name: {self.file_name}\n"
+ f"file_size: {file_size_text}\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: "kibi", 2: "mebi", 3: "gibi", 4: "tebi"}
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:
print(
RequestClient()
.client.delete("/folders", params={"folder_id": self.folder_id})
.text
)
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
@ -164,7 +81,9 @@ class ListResponse(pydantic.BaseModel):
return self.get(self.folder_id) return self.get(self.folder_id)
def create_folder(self, file_list: FileListWidget): def create_folder(self, file_list: FileListWidget):
return create_folder_widget.CreateFolderWidget(self.folder_id, file_list) return create_folder_widget.CreateFolderWidget(
self.folder_id, file_list
)
@dataclasses.dataclass(slots=True) @dataclasses.dataclass(slots=True)
@ -186,7 +105,7 @@ class TlpResponse:
def update(self) -> ResponseProtocol: def update(self) -> ResponseProtocol:
return self.get() return self.get()
def create_folder(self): def create_folder(self, _: FileListWidget):
return # Not much to do return # Not much to do
@ -208,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]
@ -228,32 +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())
@ -283,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()
@ -307,8 +212,9 @@ class Sidebar(QWidget):
("Go back", self.go_back), ("Go back", self.go_back),
("Go root", self.go_root), ("Go root", self.go_root),
("Get permitted", self.get_tlp), ("Get permitted", self.get_tlp),
("Sync", self.sync), ("Refresh", self.refresh),
("Create folder", self.create_folder), ("Create folder", self.create_folder),
("Sync all", self.sync_all),
] ]
for text, func in buttons: for text, func in buttons:
button = QPushButton(text) button = QPushButton(text)
@ -337,9 +243,8 @@ class Sidebar(QWidget):
self.file_list.responses.append(TlpResponse.get()) self.file_list.responses.append(TlpResponse.get())
self.file_list.update() self.file_list.update()
def sync(self): def refresh(self):
# TODO self.file_list.update_response()
...
def create_folder(self): def create_folder(self):
self.folder_widget = self.file_list.current_response().create_folder( self.folder_widget = self.file_list.current_response().create_folder(
@ -348,6 +253,9 @@ class Sidebar(QWidget):
if self.folder_widget is not None: if self.folder_widget is not None:
self.folder_widget.show() self.folder_widget.show()
def sync_all(self):
threading.Thread(target=sync.SyncData.sync_all).start()
class MainFileWidget(QWidget): class MainFileWidget(QWidget):
def __init__(self, state: state.State): def __init__(self, state: state.State):

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)

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)
@ -37,13 +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): def logout(self):
keyring.delete_password("auth_app", "access_token") keyring.delete_password("auth_app", "access_token")
request_client.RequestClient().delete_token() request_client.RequestClient().delete_token()
sync.SyncData.delete_all()
self.switch_to_login() 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)

View File

@ -27,9 +27,13 @@ class User(pydantic.BaseModel):
params["user_id"] = user_id params["user_id"] = user_id
else: else:
url = "/users/current" url = "/users/current"
return User.model_validate_json( response = RequestClient().client.get(url, params=params)
RequestClient().client.get(url, params=params).text if not response.is_success:
QMessageBox.warning(
None, "Error getting permissions", response.text
) )
return
return User.model_validate_json(response.text)
@staticmethod @staticmethod
def delete(): def delete():

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]