Compare commits
10 Commits
654a1e7191
...
master
Author | SHA1 | Date | |
---|---|---|---|
5f0130d0d1
|
|||
04a27b592e
|
|||
d6ecae08bd
|
|||
aa786de5b4
|
|||
409f13b584
|
|||
c101aa8aa6
|
|||
4c01ca7510
|
|||
37940d633a
|
|||
b0f20680b4
|
|||
cb3b5a3c27
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -159,3 +159,5 @@ cython_debug/
|
||||
# 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.
|
||||
#.idea/
|
||||
|
||||
database.db
|
||||
|
@ -8,11 +8,7 @@ from state import State
|
||||
if __name__ == "__main__":
|
||||
dotenv.load_dotenv()
|
||||
url = os.environ.get("DRIVE_HOST_URL")
|
||||
if not url:
|
||||
url = "localhost:3000"
|
||||
else:
|
||||
url = url.strip()
|
||||
app = QApplication(sys.argv)
|
||||
window = State(url)
|
||||
window = State()
|
||||
window.show()
|
||||
sys.exit(app.exec())
|
||||
|
@ -58,7 +58,7 @@ class RegisterWidget(QWidget):
|
||||
|
||||
try:
|
||||
response = RequestClient().client.post(
|
||||
"http://localhost:3000/users/register",
|
||||
"/users/register",
|
||||
data={
|
||||
"username": username,
|
||||
"email": email,
|
||||
@ -79,11 +79,12 @@ class RegisterWidget(QWidget):
|
||||
self, "Error", f"Registration failed: {response.text}"
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
print(e)
|
||||
QMessageBox.critical(self, "HTTP Error", str(e))
|
||||
|
||||
|
||||
class LoginWidget(QWidget):
|
||||
def __init__(self, switcher: State):
|
||||
def __init__(self, switcher: state.State):
|
||||
super().__init__()
|
||||
|
||||
self.switcher = switcher
|
||||
@ -116,12 +117,14 @@ class LoginWidget(QWidget):
|
||||
password = self.password_input.text()
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
response = RequestClient().client.post(
|
||||
"http://localhost:3000/users/authorize",
|
||||
"/users/authorize",
|
||||
data={"username": username, "password": password},
|
||||
)
|
||||
if response.is_success:
|
||||
@ -129,8 +132,12 @@ class LoginWidget(QWidget):
|
||||
if access_token:
|
||||
self.switcher.login(access_token)
|
||||
else:
|
||||
QMessageBox.warning(self, "Error", "No access token received")
|
||||
QMessageBox.warning(
|
||||
self, "Error", "No access token received"
|
||||
)
|
||||
else:
|
||||
QMessageBox.warning(self, "Error", f"Login failed: {response.text}")
|
||||
QMessageBox.warning(
|
||||
self, "Error", f"Login failed: {response.text}"
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
QMessageBox.critical(self, "HTTP Error", str(e))
|
||||
|
48
desktop_client/create_folder_widget.py
Normal file
48
desktop_client/create_folder_widget.py
Normal 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
143
desktop_client/file.py
Normal 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))
|
@ -1,13 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import dataclasses
|
||||
import uuid
|
||||
import threading
|
||||
from typing import Protocol, Self
|
||||
|
||||
import httpx
|
||||
import create_folder_widget
|
||||
import pydantic
|
||||
import state
|
||||
import sync
|
||||
import user
|
||||
from file import File
|
||||
from folder import Folder
|
||||
from PyQt6.QtCore import QPoint, Qt
|
||||
from PyQt6.QtGui import (
|
||||
QAction,
|
||||
@ -21,12 +25,9 @@ from PyQt6.QtWidgets import (
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QLabel,
|
||||
QFileDialog,
|
||||
)
|
||||
from request_client import RequestClient
|
||||
|
||||
@ -48,91 +49,6 @@ class DisplayProtocol(Protocol):
|
||||
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):
|
||||
def items(self) -> list[DisplayProtocol]:
|
||||
raise NotImplementedError
|
||||
@ -140,6 +56,9 @@ class ResponseProtocol(Protocol):
|
||||
def update(self) -> ResponseProtocol:
|
||||
raise NotImplementedError
|
||||
|
||||
def create_folder(self) -> QWidget | None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ListResponse(pydantic.BaseModel):
|
||||
folder_id: uuid.UUID
|
||||
@ -161,6 +80,34 @@ class ListResponse(pydantic.BaseModel):
|
||||
def update(self) -> ResponseProtocol:
|
||||
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):
|
||||
def __init__(self, state: state.State):
|
||||
@ -180,7 +127,7 @@ class FileListWidget(QListWidget):
|
||||
item = self.current_response().items()[row]
|
||||
item.double_click(self)
|
||||
|
||||
def current_response(self) -> ListResponse:
|
||||
def current_response(self) -> ResponseProtocol:
|
||||
if not self.responses:
|
||||
self.update_response()
|
||||
return self.responses[-1]
|
||||
@ -200,34 +147,19 @@ class FileListWidget(QListWidget):
|
||||
|
||||
def dropEvent(self, event: QDropEvent):
|
||||
event.accept()
|
||||
print("hi")
|
||||
for url in event.mimeData().urls():
|
||||
file_path = url.toLocalFile()
|
||||
self.upload_file(file_path)
|
||||
|
||||
def upload_file(self, file_path):
|
||||
file_name = os.path.basename(file_path)
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": (file_name, f)}
|
||||
response = RequestClient().client.post(
|
||||
"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()
|
||||
else:
|
||||
QMessageBox.warning(
|
||||
self, "Error", f"Upload failed: {response.text}"
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
QMessageBox.critical(self, "HTTP Error", str(e))
|
||||
def _inner():
|
||||
response = self.current_response()
|
||||
if not isinstance(response, ListResponse):
|
||||
return
|
||||
File.create(file_path, response.folder_id)
|
||||
self.update_response()
|
||||
|
||||
threading.Thread(target=_inner).start()
|
||||
|
||||
def add_item(self, item: DisplayProtocol):
|
||||
widget = QListWidgetItem(item.name())
|
||||
@ -257,7 +189,6 @@ class FileListWidget(QListWidget):
|
||||
item = self.current_response().items()[row]
|
||||
item.delete()
|
||||
self.update_response()
|
||||
QMessageBox.information(self, "Delete", f"{item.name()} deleted")
|
||||
|
||||
def update(self) -> None:
|
||||
self.clear()
|
||||
@ -271,26 +202,59 @@ class Sidebar(QWidget):
|
||||
super().__init__()
|
||||
self.state = state
|
||||
self.file_list = file_list
|
||||
self.user_widget = None
|
||||
self.folder_widget = None
|
||||
layout = QVBoxLayout()
|
||||
self.setLayout(layout)
|
||||
# Add your sidebar buttons here
|
||||
for i in range(5): # Example buttons
|
||||
btn = QPushButton(f"Button {i+1}")
|
||||
layout.addWidget(btn)
|
||||
buttons = [
|
||||
("User info", self.get_user),
|
||||
("Log out", self.logout),
|
||||
("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()
|
||||
|
||||
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):
|
||||
"""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):
|
||||
|
66
desktop_client/folder.py
Normal file
66
desktop_client/folder.py
Normal 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))
|
200
desktop_client/folder_info.py
Normal file
200
desktop_client/folder_info.py
Normal 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()
|
@ -9,9 +9,9 @@ class RequestClient:
|
||||
|
||||
def __new__(cls) -> Self:
|
||||
if cls._client is None:
|
||||
url = os.environ.get("DRIVE_HOST_URL").strip()
|
||||
if not url:
|
||||
url = "localhost:3000"
|
||||
url = os.environ.get("DRIVE_HOST_URL")
|
||||
if url is None:
|
||||
url = "https://drive.stnicolay.ru"
|
||||
cls._client = httpx.Client(base_url=url)
|
||||
return super().__new__(cls)
|
||||
|
||||
@ -19,6 +19,10 @@ class RequestClient:
|
||||
def set_token(cls, token: str):
|
||||
cls._client.headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
@classmethod
|
||||
def delete_token(cls):
|
||||
cls._client.headers = {}
|
||||
|
||||
@property
|
||||
def client(self) -> httpx.Client:
|
||||
return self._client
|
||||
|
@ -1,19 +1,18 @@
|
||||
import auth
|
||||
import file_widgets
|
||||
import httpx
|
||||
import keyring
|
||||
import request_client
|
||||
from PyQt6.QtWidgets import QMainWindow, QStackedWidget
|
||||
import sync
|
||||
from PyQt6.QtWidgets import QMainWindow, QMessageBox, QStackedWidget
|
||||
|
||||
|
||||
class State(QMainWindow):
|
||||
def __init__(self, url: str):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.setWindowTitle("Auth App")
|
||||
self.setWindowTitle("Drive application")
|
||||
|
||||
self.stack = QStackedWidget()
|
||||
self.client = httpx.Client(base_url=url)
|
||||
self.register_widget = auth.RegisterWidget(self)
|
||||
self.login_widget = auth.LoginWidget(self)
|
||||
|
||||
@ -27,8 +26,8 @@ class State(QMainWindow):
|
||||
password = keyring.get_credential("auth_app", "access_token")
|
||||
if password is None:
|
||||
self.switch_to_login() # Start with the login widget
|
||||
|
||||
self.login(password.password)
|
||||
else:
|
||||
self.login(password.password)
|
||||
|
||||
def switch_to_login(self):
|
||||
self.stack.setCurrentWidget(self.login_widget)
|
||||
@ -37,8 +36,21 @@ class State(QMainWindow):
|
||||
self.stack.setCurrentWidget(self.register_widget)
|
||||
|
||||
def login(self, token: str):
|
||||
keyring.set_password("auth_app", "access_token", token)
|
||||
request_client.RequestClient().set_token(token)
|
||||
self.file_widget = file_widgets.MainFileWidget(self)
|
||||
self.stack.addWidget(self.file_widget)
|
||||
self.stack.setCurrentWidget(self.file_widget)
|
||||
try:
|
||||
keyring.set_password("auth_app", "access_token", token)
|
||||
request_client.RequestClient().set_token(token)
|
||||
self.file_widget = file_widgets.MainFileWidget(self)
|
||||
self.stack.addWidget(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
148
desktop_client/sync.py
Normal 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
101
desktop_client/user.py
Normal 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
12
desktop_client/utils.py
Normal 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
43
poetry.lock
generated
@ -427,18 +427,18 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-ena
|
||||
|
||||
[[package]]
|
||||
name = "jaraco-context"
|
||||
version = "5.3.0"
|
||||
version = "6.0.1"
|
||||
description = "Useful decorators and context managers"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "jaraco.context-5.3.0-py3-none-any.whl", hash = "sha256:3e16388f7da43d384a1a7cd3452e72e14732ac9fe459678773a3608a812bf266"},
|
||||
{file = "jaraco.context-5.3.0.tar.gz", hash = "sha256:c2f67165ce1f9be20f32f650f25d8edfc1646a8aeee48ae06fb35f90763576d2"},
|
||||
{file = "jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4"},
|
||||
{file = "jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
docs = ["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)"]
|
||||
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
|
||||
test = ["portend", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "jaraco-functools"
|
||||
@ -788,6 +788,20 @@ files = [
|
||||
{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]]
|
||||
name = "python-dotenv"
|
||||
version = "1.0.1"
|
||||
@ -804,13 +818,13 @@ cli = ["click (>=5.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32-ctypes"
|
||||
version = "0.2.2"
|
||||
version = "0.2.3"
|
||||
description = "A (partial) reimplementation of pywin32 using ctypes/cffi"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
{file = "pywin32-ctypes-0.2.2.tar.gz", hash = "sha256:3426e063bdd5fd4df74a14fa3cf80a0b42845a87e1d1e81f6549f9daec593a60"},
|
||||
{file = "pywin32_ctypes-0.2.2-py3-none-any.whl", hash = "sha256:bf490a1a709baf35d688fe0ecf980ed4de11d2b3e37b51e5442587a75d9957e7"},
|
||||
{file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"},
|
||||
{file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -828,6 +842,17 @@ files = [
|
||||
cryptography = ">=2.0"
|
||||
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]]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
@ -955,4 +980,4 @@ files = [
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "6e84cef360a53faa7bba2d9bb57f01093ff0291c28c02aff4727b6c21fd6d715"
|
||||
content-hash = "fd70e86969d9632a562a6755e8ff2f7e9d38e795da2f3c5c04eabe77d5723fdb"
|
||||
|
@ -13,6 +13,7 @@ pyqt6 = "^6.7.1"
|
||||
keyring = "^25.3.0"
|
||||
python-dotenv = "^1.0.1"
|
||||
sqlmodel = "^0.0.21"
|
||||
python-dateutil = "^2.9.0.post0"
|
||||
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
|
Reference in New Issue
Block a user