Compare commits
No commits in common. "ai-dev" and "master" have entirely different histories.
@ -1,12 +0,0 @@
|
||||
# Fixed the `AttributeError` at `/flower/`.
|
||||
|
||||
Plan:
|
||||
- Add `REVPROXY = {}` to `config/settings.py` to fix the missing setting error from `django-revproxy`.
|
||||
- Restart `django-dev.service`.
|
||||
- Verify port 5555 is listening (Flower).
|
||||
|
||||
Changed:
|
||||
* `config/settings.py`: Added `REVPROXY = {}`.
|
||||
|
||||
Next: Try accessing `/flower/` again.
|
||||
Reminder: click Save in the editor to sync changes.
|
||||
Binary file not shown.
Binary file not shown.
@ -1,3 +1,37 @@
|
||||
"""
|
||||
LocalAIApi — lightweight Python client for the Flatlogic AI proxy.
|
||||
|
||||
Usage (inside the Django workspace):
|
||||
|
||||
from ai.local_ai_api import LocalAIApi
|
||||
|
||||
response = LocalAIApi.create_response({
|
||||
"input": [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Summarise this text in two sentences."},
|
||||
],
|
||||
"text": {"format": {"type": "json_object"}},
|
||||
})
|
||||
|
||||
if response.get("success"):
|
||||
data = LocalAIApi.decode_json_from_response(response)
|
||||
# ...
|
||||
|
||||
# Typical successful payload (truncated):
|
||||
# {
|
||||
# "id": "resp_xxx",
|
||||
# "status": "completed",
|
||||
# "output": [
|
||||
# {"type": "reasoning", "summary": []},
|
||||
# {"type": "message", "content": [{"type": "output_text", "text": "Your final answer here."}]}
|
||||
# ],
|
||||
# "usage": { "input_tokens": 123, "output_tokens": 456 }
|
||||
# }
|
||||
|
||||
The helper automatically injects the project UUID header and falls back to
|
||||
reading executor/.env if environment variables are missing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@ -18,8 +52,10 @@ __all__ = [
|
||||
"decode_json_from_response",
|
||||
]
|
||||
|
||||
|
||||
_CONFIG_CACHE: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class LocalAIApi:
|
||||
"""Static helpers mirroring the PHP implementation."""
|
||||
|
||||
@ -40,7 +76,9 @@ class LocalAIApi:
|
||||
def decode_json_from_response(response: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return decode_json_from_response(response)
|
||||
|
||||
|
||||
def create_response(params: Dict[str, Any], options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Signature compatible with the OpenAI Responses API."""
|
||||
options = options or {}
|
||||
payload = dict(params)
|
||||
|
||||
@ -73,7 +111,9 @@ def create_response(params: Dict[str, Any], options: Optional[Dict[str, Any]] =
|
||||
|
||||
return initial
|
||||
|
||||
|
||||
def request(path: Optional[str], payload: Dict[str, Any], options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Perform a raw request to the AI proxy."""
|
||||
cfg = _config()
|
||||
options = options or {}
|
||||
|
||||
@ -105,7 +145,6 @@ def request(path: Optional[str], payload: Dict[str, Any], options: Optional[Dict
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
cfg["project_header"]: project_uuid,
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
}
|
||||
extra_headers = options.get("headers")
|
||||
if isinstance(extra_headers, Iterable):
|
||||
@ -117,7 +156,9 @@ def request(path: Optional[str], payload: Dict[str, Any], options: Optional[Dict
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
return _http_request(url, "POST", body, headers, timeout, verify_tls)
|
||||
|
||||
|
||||
def fetch_status(ai_request_id: Any, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Fetch status for a queued AI request."""
|
||||
cfg = _config()
|
||||
options = options or {}
|
||||
|
||||
@ -139,7 +180,6 @@ def fetch_status(ai_request_id: Any, options: Optional[Dict[str, Any]] = None) -
|
||||
headers: Dict[str, str] = {
|
||||
"Accept": "application/json",
|
||||
cfg["project_header"]: project_uuid,
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
}
|
||||
extra_headers = options.get("headers")
|
||||
if isinstance(extra_headers, Iterable):
|
||||
@ -150,7 +190,9 @@ def fetch_status(ai_request_id: Any, options: Optional[Dict[str, Any]] = None) -
|
||||
|
||||
return _http_request(url, "GET", None, headers, timeout, verify_tls)
|
||||
|
||||
|
||||
def await_response(ai_request_id: Any, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Poll status endpoint until the request is complete or timed out."""
|
||||
options = options or {}
|
||||
timeout = int(options.get("timeout", 300))
|
||||
interval = int(options.get("interval", 5))
|
||||
@ -194,10 +236,14 @@ def await_response(ai_request_id: Any, options: Optional[Dict[str, Any]] = None)
|
||||
}
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
def extract_text(response: Dict[str, Any]) -> str:
|
||||
"""Public helper to extract plain text from a Responses payload."""
|
||||
return _extract_text(response)
|
||||
|
||||
|
||||
def decode_json_from_response(response: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Attempt to decode JSON emitted by the model (handles markdown fences)."""
|
||||
text = _extract_text(response)
|
||||
if text == "":
|
||||
return None
|
||||
@ -224,6 +270,7 @@ def decode_json_from_response(response: Dict[str, Any]) -> Optional[Dict[str, An
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _extract_text(response: Dict[str, Any]) -> str:
|
||||
payload = response.get("data") if response.get("success") else response.get("response")
|
||||
if isinstance(payload, dict):
|
||||
@ -247,8 +294,9 @@ def _extract_text(response: Dict[str, Any]) -> str:
|
||||
return payload
|
||||
return ""
|
||||
|
||||
|
||||
def _config() -> Dict[str, Any]:
|
||||
global _CONFIG_CACHE
|
||||
global _CONFIG_CACHE # noqa: PLW0603
|
||||
if _CONFIG_CACHE is not None:
|
||||
return _CONFIG_CACHE
|
||||
|
||||
@ -272,6 +320,7 @@ def _config() -> Dict[str, Any]:
|
||||
}
|
||||
return _CONFIG_CACHE
|
||||
|
||||
|
||||
def _build_url(path: str, base_url: str) -> str:
|
||||
trimmed = path.strip()
|
||||
if trimmed.startswith("http://") or trimmed.startswith("https://"):
|
||||
@ -280,6 +329,7 @@ def _build_url(path: str, base_url: str) -> str:
|
||||
return f"{base_url}{trimmed}"
|
||||
return f"{base_url}/{trimmed}"
|
||||
|
||||
|
||||
def _resolve_status_path(ai_request_id: Any, cfg: Dict[str, Any]) -> str:
|
||||
base_path = (cfg.get("responses_path") or "").rstrip("/")
|
||||
if not base_path:
|
||||
@ -288,8 +338,12 @@ def _resolve_status_path(ai_request_id: Any, cfg: Dict[str, Any]) -> str:
|
||||
base_path = f"{base_path}/ai-request"
|
||||
return f"{base_path}/{ai_request_id}/status"
|
||||
|
||||
|
||||
def _http_request(url: str, method: str, body: Optional[bytes], headers: Dict[str, str],
|
||||
timeout: int, verify_tls: bool) -> Dict[str, Any]:
|
||||
"""
|
||||
Shared HTTP helper for GET/POST requests.
|
||||
"""
|
||||
req = urlrequest.Request(url, data=body, method=method.upper())
|
||||
for name, value in headers.items():
|
||||
req.add_header(name, value)
|
||||
@ -307,7 +361,7 @@ def _http_request(url: str, method: str, body: Optional[bytes], headers: Dict[st
|
||||
except urlerror.HTTPError as exc:
|
||||
status = exc.getcode()
|
||||
response_body = exc.read().decode("utf-8", errors="replace")
|
||||
except Exception as exc:
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
return {
|
||||
"success": False,
|
||||
"error": "request_failed",
|
||||
@ -341,7 +395,9 @@ def _http_request(url: str, method: str, body: Optional[bytes], headers: Dict[st
|
||||
"response": decoded if decoded is not None else response_body,
|
||||
}
|
||||
|
||||
|
||||
def _ensure_env_loaded() -> None:
|
||||
"""Populate os.environ from executor/.env if variables are missing."""
|
||||
if os.getenv("PROJECT_UUID") and os.getenv("PROJECT_ID"):
|
||||
return
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.2 MiB |
224
celery.log
224
celery.log
@ -1,224 +0,0 @@
|
||||
|
||||
-------------- celery@pool-python-d07be985 v5.4.0 (opalescent)
|
||||
--- ***** -----
|
||||
-- ******* ---- Linux-6.1.0-42-cloud-amd64-x86_64-with-glibc2.36 2026-02-08 16:57:58
|
||||
- *** --- * ---
|
||||
- ** ---------- [config]
|
||||
- ** ---------- .> app: config:0x7f0365fc7010
|
||||
- ** ---------- .> transport: redis://localhost:6379/0
|
||||
- ** ---------- .> results: redis://localhost:6379/0
|
||||
- *** --- * --- .> concurrency: 2 (prefork)
|
||||
-- ******* ---- .> task events: OFF (enable -E to monitor tasks in this worker)
|
||||
--- ***** -----
|
||||
-------------- [queues]
|
||||
.> celery exchange=celery(direct) key=celery
|
||||
|
||||
|
||||
[tasks]
|
||||
. config.celery.debug_task
|
||||
. core.tasks.generate_summary
|
||||
. core.tasks.process_bookmark
|
||||
|
||||
[2026-02-08 16:57:58,648: WARNING/MainProcess] /home/ubuntu/.local/lib/python3.11/site-packages/celery/worker/consumer/consumer.py:508: CPendingDeprecationWarning: The broker_connection_retry configuration setting will no longer determine
|
||||
whether broker connection retries are made during startup in Celery 6.0 and above.
|
||||
If you wish to retain the existing behavior for retrying connections on startup,
|
||||
you should set broker_connection_retry_on_startup to True.
|
||||
warnings.warn(
|
||||
|
||||
[2026-02-08 16:57:58,669: INFO/MainProcess] Connected to redis://localhost:6379/0
|
||||
[2026-02-08 16:57:58,672: WARNING/MainProcess] /home/ubuntu/.local/lib/python3.11/site-packages/celery/worker/consumer/consumer.py:508: CPendingDeprecationWarning: The broker_connection_retry configuration setting will no longer determine
|
||||
whether broker connection retries are made during startup in Celery 6.0 and above.
|
||||
If you wish to retain the existing behavior for retrying connections on startup,
|
||||
you should set broker_connection_retry_on_startup to True.
|
||||
warnings.warn(
|
||||
|
||||
[2026-02-08 16:57:58,676: INFO/MainProcess] mingle: searching for neighbors
|
||||
[2026-02-08 16:57:59,699: INFO/MainProcess] mingle: all alone
|
||||
[2026-02-08 16:57:59,722: INFO/MainProcess] celery@pool-python-d07be985 ready.
|
||||
[2026-02-08 16:58:54,807: INFO/MainProcess] Task core.tasks.process_bookmark[6aeefd1e-df28-42d3-bb17-d6836c389361] received
|
||||
[2026-02-08 16:58:55,106: INFO/ForkPoolWorker-2] HTTP Request: GET https://openai.com "HTTP/1.1 403 Forbidden"
|
||||
[2026-02-08 16:58:55,121: WARNING/ForkPoolWorker-2] Error fetching bookmark 9 (https://openai.com): Client error '403 Forbidden' for url 'https://openai.com'
|
||||
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403. Trying base domain backup.
|
||||
[2026-02-08 16:58:55,121: ERROR/ForkPoolWorker-2] Error fetching base domain for bookmark 9: Client error '403 Forbidden' for url 'https://openai.com'
|
||||
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403
|
||||
[2026-02-08 16:58:55,222: INFO/MainProcess] Task core.tasks.generate_summary[b7f3ce50-3949-4d4b-8124-986fc6fed32d] received
|
||||
[2026-02-08 16:58:55,230: INFO/ForkPoolWorker-2] Task core.tasks.process_bookmark[6aeefd1e-df28-42d3-bb17-d6836c389361] succeeded in 0.41664800100261346s: 'Processed bookmark 9'
|
||||
[2026-02-08 16:59:06,405: INFO/ForkPoolWorker-1] Task core.tasks.generate_summary[b7f3ce50-3949-4d4b-8124-986fc6fed32d] succeeded in 11.157703020959161s: 'Generated summary for bookmark 9'
|
||||
[2026-02-08 17:49:01,106: INFO/MainProcess] Task core.tasks.process_bookmark[480f8119-0f34-4c8d-af74-8223f6d86777] received
|
||||
[2026-02-08 17:49:01,495: INFO/ForkPoolWorker-2] HTTP Request: GET https://aimlapi.com/ "HTTP/1.1 200 OK"
|
||||
[2026-02-08 17:49:01,833: INFO/MainProcess] Task core.tasks.generate_summary[5f9a1a29-9b56-4219-b073-86aa584fa574] received
|
||||
[2026-02-08 17:49:01,840: INFO/ForkPoolWorker-2] Task core.tasks.process_bookmark[480f8119-0f34-4c8d-af74-8223f6d86777] succeeded in 0.7128028109436855s: 'Processed bookmark 10'
|
||||
[2026-02-08 17:49:12,749: INFO/ForkPoolWorker-1] Task core.tasks.generate_summary[5f9a1a29-9b56-4219-b073-86aa584fa574] succeeded in 10.906358937965706s: 'Generated summary for bookmark 10'
|
||||
[2026-02-08 17:50:13,199: INFO/MainProcess] Task core.tasks.process_bookmark[38a7f2f4-6b5e-48ab-8ce5-b973aecf0829] received
|
||||
[2026-02-08 17:50:13,401: INFO/ForkPoolWorker-2] HTTP Request: GET https://dropoverapp.com/ "HTTP/1.1 200 OK"
|
||||
[2026-02-08 17:50:13,476: INFO/MainProcess] Task core.tasks.generate_summary[f6142f4c-c377-44cf-8921-4de37564f845] received
|
||||
[2026-02-08 17:50:13,486: INFO/ForkPoolWorker-2] Task core.tasks.process_bookmark[38a7f2f4-6b5e-48ab-8ce5-b973aecf0829] succeeded in 0.28576223901472986s: 'Processed bookmark 11'
|
||||
[2026-02-08 17:50:19,030: INFO/ForkPoolWorker-1] Task core.tasks.generate_summary[f6142f4c-c377-44cf-8921-4de37564f845] succeeded in 5.548579726018943s: 'Generated summary for bookmark 11'
|
||||
[2026-02-08 17:54:37,900: INFO/MainProcess] Task core.tasks.process_bookmark[49edb552-63b2-44e3-b6bf-29ea3921cbc0] received
|
||||
[2026-02-08 17:54:39,151: INFO/ForkPoolWorker-2] HTTP Request: GET https://www.wikipedia.org/ "HTTP/1.1 200 OK"
|
||||
[2026-02-08 17:54:39,938: INFO/MainProcess] Task core.tasks.generate_summary[62fbb6ba-951b-469a-ac79-19663a33d9a4] received
|
||||
[2026-02-08 17:54:39,982: INFO/ForkPoolWorker-2] Task core.tasks.process_bookmark[49edb552-63b2-44e3-b6bf-29ea3921cbc0] succeeded in 1.9269755689892918s: 'Processed bookmark 12'
|
||||
[2026-02-08 17:54:46,341: INFO/ForkPoolWorker-1] Task core.tasks.generate_summary[62fbb6ba-951b-469a-ac79-19663a33d9a4] succeeded in 6.309643072017934s: 'Generated summary for bookmark 12'
|
||||
[2026-02-08 17:55:04,551: INFO/MainProcess] Task core.tasks.process_bookmark[20ea2b59-ed0f-4970-ad43-cea2bc3597e0] received
|
||||
[2026-02-08 17:55:04,768: INFO/ForkPoolWorker-2] HTTP Request: GET https://www.wikipedia.org/ "HTTP/1.1 200 OK"
|
||||
[2026-02-08 17:55:05,030: INFO/MainProcess] Task core.tasks.generate_summary[e29ad0ec-2683-44f9-964a-6883351d3e53] received
|
||||
[2026-02-08 17:55:05,034: INFO/ForkPoolWorker-2] Task core.tasks.process_bookmark[20ea2b59-ed0f-4970-ad43-cea2bc3597e0] succeeded in 0.4778669200022705s: 'Processed bookmark 12'
|
||||
[2026-02-08 17:55:15,878: INFO/ForkPoolWorker-1] Task core.tasks.generate_summary[e29ad0ec-2683-44f9-964a-6883351d3e53] succeeded in 10.846816856996156s: 'Generated summary for bookmark 12'
|
||||
[2026-02-08 17:55:38,808: INFO/MainProcess] Task core.tasks.process_bookmark[631857db-83e4-448e-91bc-5964ff822b82] received
|
||||
[2026-02-08 17:55:38,955: INFO/ForkPoolWorker-2] HTTP Request: GET https://openai.com "HTTP/1.1 403 Forbidden"
|
||||
[2026-02-08 17:55:38,975: WARNING/ForkPoolWorker-2] Error fetching bookmark 9 (https://openai.com): Client error '403 Forbidden' for url 'https://openai.com'
|
||||
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403. Trying base domain backup.
|
||||
[2026-02-08 17:55:38,976: ERROR/ForkPoolWorker-2] Error fetching base domain for bookmark 9: Client error '403 Forbidden' for url 'https://openai.com'
|
||||
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403
|
||||
[2026-02-08 17:55:38,992: INFO/MainProcess] Task core.tasks.generate_summary[ac5da805-5c8e-4641-ba7d-594b569f9472] received
|
||||
[2026-02-08 17:55:38,996: INFO/ForkPoolWorker-2] Task core.tasks.process_bookmark[631857db-83e4-448e-91bc-5964ff822b82] succeeded in 0.18439359701005742s: 'Processed bookmark 9'
|
||||
[2026-02-08 17:55:49,704: INFO/ForkPoolWorker-1] Task core.tasks.generate_summary[ac5da805-5c8e-4641-ba7d-594b569f9472] succeeded in 10.710596179007553s: 'Generated summary for bookmark 9'
|
||||
[2026-02-08 17:56:04,496: INFO/MainProcess] Task core.tasks.process_bookmark[8e44cfbd-da5d-4f2c-a670-205d1f35397c] received
|
||||
[2026-02-08 17:56:06,477: INFO/ForkPoolWorker-2] HTTP Request: GET https://www.wikipedia.org/ "HTTP/1.1 200 OK"
|
||||
[2026-02-08 17:56:10,002: INFO/MainProcess] Task core.tasks.generate_summary[d89d853b-545c-47ef-8d88-b3645948b928] received
|
||||
[2026-02-08 17:56:10,231: INFO/ForkPoolWorker-2] Task core.tasks.process_bookmark[8e44cfbd-da5d-4f2c-a670-205d1f35397c] succeeded in 5.731945501989685s: 'Processed bookmark 12'
|
||||
[2026-02-08 17:56:23,118: INFO/ForkPoolWorker-1] Task core.tasks.generate_summary[d89d853b-545c-47ef-8d88-b3645948b928] succeeded in 13.114061183994636s: 'Generated summary for bookmark 12'
|
||||
|
||||
-------------- celery@pool-python-d07be985 v5.4.0 (opalescent)
|
||||
--- ***** -----
|
||||
-- ******* ---- Linux-6.1.0-42-cloud-amd64-x86_64-with-glibc2.36 2026-02-08 19:37:54
|
||||
- *** --- * ---
|
||||
- ** ---------- [config]
|
||||
- ** ---------- .> app: config:0x7f7767509510
|
||||
- ** ---------- .> transport: redis://localhost:6379/0
|
||||
- ** ---------- .> results: redis://localhost:6379/0
|
||||
- *** --- * --- .> concurrency: 2 (prefork)
|
||||
-- ******* ---- .> task events: OFF (enable -E to monitor tasks in this worker)
|
||||
--- ***** -----
|
||||
-------------- [queues]
|
||||
.> celery exchange=celery(direct) key=celery
|
||||
|
||||
|
||||
[tasks]
|
||||
. config.celery.debug_task
|
||||
. core.tasks.generate_summary
|
||||
. core.tasks.process_bookmark
|
||||
|
||||
[2026-02-08 19:37:55,136: WARNING/MainProcess] /home/ubuntu/.local/lib/python3.11/site-packages/celery/worker/consumer/consumer.py:508: CPendingDeprecationWarning: The broker_connection_retry configuration setting will no longer determine
|
||||
whether broker connection retries are made during startup in Celery 6.0 and above.
|
||||
If you wish to retain the existing behavior for retrying connections on startup,
|
||||
you should set broker_connection_retry_on_startup to True.
|
||||
warnings.warn(
|
||||
|
||||
[2026-02-08 19:37:55,230: INFO/MainProcess] Connected to redis://localhost:6379/0
|
||||
[2026-02-08 19:37:55,233: WARNING/MainProcess] /home/ubuntu/.local/lib/python3.11/site-packages/celery/worker/consumer/consumer.py:508: CPendingDeprecationWarning: The broker_connection_retry configuration setting will no longer determine
|
||||
whether broker connection retries are made during startup in Celery 6.0 and above.
|
||||
If you wish to retain the existing behavior for retrying connections on startup,
|
||||
you should set broker_connection_retry_on_startup to True.
|
||||
warnings.warn(
|
||||
|
||||
[2026-02-08 19:37:55,266: INFO/MainProcess] mingle: searching for neighbors
|
||||
[2026-02-08 19:37:56,302: INFO/MainProcess] mingle: all alone
|
||||
[2026-02-08 19:37:56,334: INFO/MainProcess] celery@pool-python-d07be985 ready.
|
||||
[2026-02-08 19:37:56,351: INFO/MainProcess] Task core.tasks.process_bookmark[49276cb8-da4c-4e3b-a10c-1eff3e4358fc] received
|
||||
[2026-02-08 19:37:56,364: INFO/MainProcess] Task core.tasks.process_bookmark[4cc4cf11-e28f-4a72-9d09-4da9b24bce6e] received
|
||||
[2026-02-08 19:37:56,819: INFO/ForkPoolWorker-2] HTTP Request: GET https://dropoverapp.com/ "HTTP/1.1 200 OK"
|
||||
[2026-02-08 19:37:56,955: INFO/ForkPoolWorker-1] HTTP Request: GET https://www.strella.io/ "HTTP/1.1 200 OK"
|
||||
[2026-02-08 19:37:57,117: INFO/MainProcess] Task core.tasks.generate_summary[7ca1dbb6-005a-4fcf-b5d0-5fd6a2ae8ac5] received
|
||||
[2026-02-08 19:37:57,130: INFO/ForkPoolWorker-2] Task core.tasks.process_bookmark[49276cb8-da4c-4e3b-a10c-1eff3e4358fc] succeeded in 0.7769331529852934s: 'Processed bookmark 13'
|
||||
[2026-02-08 19:37:57,193: INFO/ForkPoolWorker-2] Generating summary/tags for bookmark 13...
|
||||
[2026-02-08 19:37:57,361: INFO/MainProcess] Task core.tasks.generate_summary[71b0f9cb-36dc-4eb4-91f7-f6e8bba9c54d] received
|
||||
[2026-02-08 19:37:57,389: INFO/ForkPoolWorker-1] Task core.tasks.process_bookmark[4cc4cf11-e28f-4a72-9d09-4da9b24bce6e] succeeded in 1.0219957570079714s: 'Processed bookmark 14'
|
||||
[2026-02-08 19:37:57,419: INFO/ForkPoolWorker-1] Generating summary/tags for bookmark 14...
|
||||
[2026-02-08 19:37:58,896: INFO/MainProcess] Events of group {task} enabled by remote.
|
||||
[2026-02-08 19:38:07,952: INFO/ForkPoolWorker-2] AI Raw Response for 13: {
|
||||
"summary": "Dropover is a macOS utility that provides floating shelves to collect, organize, and batch-move dragged items (files, folders, images, URLs, text) for streamlined file management. It offers built-in file actions, cloud uploads and sharing, plus extensive customization and automation features (custom actions, scripts, keyboard shortcuts, Siri Shortcuts) for power users.",
|
||||
"tags": ["filesharing", "sharing", "documents", "productivity"]
|
||||
}
|
||||
[2026-02-08 19:38:07,953: INFO/ForkPoolWorker-2] Decoded JSON for 13: summary=True, tags=['filesharing', 'sharing', 'documents', 'productivity']
|
||||
[2026-02-08 19:38:08,022: INFO/ForkPoolWorker-2] Successfully added tags ['filesharing', 'sharing', 'documents', 'productivity'] to bookmark 13
|
||||
[2026-02-08 19:38:08,024: INFO/ForkPoolWorker-2] Task core.tasks.generate_summary[7ca1dbb6-005a-4fcf-b5d0-5fd6a2ae8ac5] succeeded in 10.876905062003061s: 'Generated summary and tags for bookmark 13'
|
||||
[2026-02-08 19:38:08,341: INFO/ForkPoolWorker-1] AI Raw Response for 14: {
|
||||
"summary": "Strella is an AI-powered customer research platform that runs AI-moderated interviews, recruits participants, generates discussion guides, and analyzes responses to deliver actionable insights within hours. It’s built for teams (UX, product, consumer insights, marketing) to accelerate market research, usability testing, and concept validation and to share findings with stakeholders.",
|
||||
"tags": ["marketing", "company", "sharing", "development"]
|
||||
}
|
||||
[2026-02-08 19:38:08,342: INFO/ForkPoolWorker-1] Decoded JSON for 14: summary=True, tags=['marketing', 'company', 'sharing', 'development']
|
||||
[2026-02-08 19:38:08,422: INFO/ForkPoolWorker-1] Successfully added tags ['marketing', 'company', 'sharing', 'development'] to bookmark 14
|
||||
[2026-02-08 19:38:08,426: INFO/ForkPoolWorker-1] Task core.tasks.generate_summary[71b0f9cb-36dc-4eb4-91f7-f6e8bba9c54d] succeeded in 11.034683802979998s: 'Generated summary and tags for bookmark 14'
|
||||
[2026-02-09 04:08:42,072: INFO/MainProcess] Task core.tasks.process_bookmark[cd622357-234e-4dc9-9fab-eefa651ea2a4] received
|
||||
[2026-02-09 04:08:43,746: INFO/ForkPoolWorker-2] HTTP Request: GET https://www.strella.io/ "HTTP/1.1 200 OK"
|
||||
[2026-02-09 04:08:45,048: INFO/MainProcess] Task core.tasks.generate_summary[ba68ccad-d413-41cc-8b1c-65384d2792ab] received
|
||||
[2026-02-09 04:08:45,073: INFO/ForkPoolWorker-2] Task core.tasks.process_bookmark[cd622357-234e-4dc9-9fab-eefa651ea2a4] succeeded in 2.6813507160404697s: 'Processed bookmark 15'
|
||||
[2026-02-09 04:08:45,647: INFO/ForkPoolWorker-1] Generating summary/tags for bookmark 15...
|
||||
[2026-02-09 04:08:51,671: INFO/ForkPoolWorker-1] AI Raw Response for 15: {
|
||||
"summary": "Strella is an AI-powered customer research platform that runs AI-moderated interviews, recruits targeted participants, and analyzes responses to generate actionable insights in hours. It’s designed for teams (UX, product, consumer insights, marketing) to accelerate research workflows, produce unbiased discussion guides, and share findings with stakeholders.",
|
||||
"tags": ["marketing", "company", "productivity", "sharing"]
|
||||
}
|
||||
[2026-02-09 04:08:51,673: INFO/ForkPoolWorker-1] Decoded JSON for 15: summary=True, tags=['marketing', 'company', 'productivity', 'sharing']
|
||||
[2026-02-09 04:08:51,759: INFO/ForkPoolWorker-1] Successfully added tags ['marketing', 'company', 'productivity', 'sharing'] to bookmark 15
|
||||
[2026-02-09 04:08:51,894: INFO/ForkPoolWorker-1] Task core.tasks.generate_summary[ba68ccad-d413-41cc-8b1c-65384d2792ab] succeeded in 6.7007439360022545s: 'Generated summary and tags for bookmark 15'
|
||||
[2026-02-09 18:30:44,317: INFO/MainProcess] Task core.tasks.process_bookmark[db2f07fc-75d0-4a11-961e-0a34e1cde744] received
|
||||
[2026-02-09 18:30:48,712: INFO/ForkPoolWorker-2] HTTP Request: GET https://github.com/pydantic/monty "HTTP/1.1 200 OK"
|
||||
[2026-02-09 18:30:50,263: INFO/MainProcess] Task core.tasks.generate_summary[944ef664-f321-45b6-82f8-607291008ea6] received
|
||||
[2026-02-09 18:30:50,374: INFO/ForkPoolWorker-2] Task core.tasks.process_bookmark[db2f07fc-75d0-4a11-961e-0a34e1cde744] succeeded in 5.521026417030953s: 'Processed bookmark 27'
|
||||
[2026-02-09 18:30:51,857: INFO/ForkPoolWorker-1] Generating summary/tags for bookmark 27...
|
||||
[2026-02-09 18:31:03,704: INFO/ForkPoolWorker-1] AI Raw Response for 27: {
|
||||
"summary": "monty is a minimal, secure Python interpreter implemented in Rust, designed for safely executing Python code in AI contexts. The GitHub repository from pydantic provides the codebase and tooling to sandbox and integrate this interpreter for AI agents and applications.",
|
||||
"tags": ["development", "research", "new", "company"]
|
||||
}
|
||||
[2026-02-09 18:31:03,713: INFO/ForkPoolWorker-1] Decoded JSON for 27: summary=True, tags=['development', 'research', 'new', 'company']
|
||||
[2026-02-09 18:31:03,987: INFO/ForkPoolWorker-1] Successfully added tags ['development', 'research', 'new', 'company'] to bookmark 27
|
||||
[2026-02-09 18:31:04,321: INFO/ForkPoolWorker-1] Task core.tasks.generate_summary[944ef664-f321-45b6-82f8-607291008ea6] succeeded in 13.666462198016234s: 'Generated summary and tags for bookmark 27'
|
||||
[2026-02-09 19:04:23,495: INFO/MainProcess] Task core.tasks.process_bookmark[70bb649c-6afd-477d-ac5f-959b9ea12bb0] received
|
||||
[2026-02-09 19:04:26,064: INFO/ForkPoolWorker-2] HTTP Request: GET https://facebook.com "HTTP/1.1 301 Moved Permanently"
|
||||
[2026-02-09 19:04:26,361: INFO/ForkPoolWorker-2] HTTP Request: GET https://www.facebook.com/ "HTTP/1.1 400 Bad Request"
|
||||
[2026-02-09 19:04:26,399: WARNING/ForkPoolWorker-2] Error fetching bookmark 28 (https://facebook.com): Client error '400 Bad Request' for url 'https://www.facebook.com/'
|
||||
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400. Trying base domain backup.
|
||||
[2026-02-09 19:04:26,403: ERROR/ForkPoolWorker-2] Error fetching base domain for bookmark 28: Client error '400 Bad Request' for url 'https://www.facebook.com/'
|
||||
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400
|
||||
[2026-02-09 19:04:27,088: INFO/MainProcess] Task core.tasks.generate_summary[b0feb8a7-8741-4581-a7f0-7e703e4a2e70] received
|
||||
[2026-02-09 19:04:27,151: INFO/ForkPoolWorker-2] Task core.tasks.process_bookmark[70bb649c-6afd-477d-ac5f-959b9ea12bb0] succeeded in 3.190528256993275s: 'Processed bookmark 28'
|
||||
[2026-02-09 19:04:28,313: INFO/ForkPoolWorker-1] Generating summary/tags for bookmark 28...
|
||||
[2026-02-09 19:04:39,614: INFO/ForkPoolWorker-1] AI Raw Response for 28: {
|
||||
"summary": "The webpage content could not be retrieved; requests to https://facebook.com and its base domain failed. No content was available for analysis, so no page details can be provided."
|
||||
}
|
||||
[2026-02-09 19:04:39,614: INFO/ForkPoolWorker-1] Decoded JSON for 28: summary=True, tags=[]
|
||||
[2026-02-09 19:04:40,920: INFO/ForkPoolWorker-1] Task core.tasks.generate_summary[b0feb8a7-8741-4581-a7f0-7e703e4a2e70] succeeded in 13.627130784036126s: 'Generated summary for bookmark 28'
|
||||
[2026-02-09 19:08:50,036: INFO/MainProcess] Task core.tasks.process_bookmark[ff409078-4458-47ef-b732-facaaf2cd761] received
|
||||
[2026-02-09 19:08:50,262: INFO/ForkPoolWorker-2] HTTP Request: GET https://youtube.com "HTTP/1.1 301 Moved Permanently"
|
||||
[2026-02-09 19:08:50,403: INFO/ForkPoolWorker-2] HTTP Request: GET https://www.youtube.com/ "HTTP/1.1 200 OK"
|
||||
[2026-02-09 19:08:50,858: INFO/MainProcess] Task core.tasks.generate_summary[57582068-b4a2-48dc-9357-6d22a1ba1778] received
|
||||
[2026-02-09 19:08:50,865: INFO/ForkPoolWorker-2] Task core.tasks.process_bookmark[ff409078-4458-47ef-b732-facaaf2cd761] succeeded in 0.824686923995614s: 'Processed bookmark 29'
|
||||
[2026-02-09 19:08:50,927: INFO/ForkPoolWorker-1] Generating summary/tags for bookmark 29...
|
||||
[2026-02-09 19:09:01,754: INFO/ForkPoolWorker-1] AI Raw Response for 29: {
|
||||
"summary": "This is a YouTube footer listing navigational and legal links (About, Press, Copyright, Contact, Creators, Advertise, Developers, Terms, Privacy, Policy & Safety, How YouTube works, and Test new features). It also includes a 2026 copyright attribution to Google LLC."
|
||||
}
|
||||
[2026-02-09 19:09:01,754: INFO/ForkPoolWorker-1] Decoded JSON for 29: summary=True, tags=[]
|
||||
[2026-02-09 19:09:01,769: INFO/ForkPoolWorker-1] Task core.tasks.generate_summary[57582068-b4a2-48dc-9357-6d22a1ba1778] succeeded in 10.908533619949594s: 'Generated summary for bookmark 29'
|
||||
[2026-02-09 19:09:42,382: INFO/MainProcess] Task core.tasks.process_bookmark[21590aeb-6045-4341-8f97-80291d55e5bb] received
|
||||
[2026-02-09 19:09:42,476: INFO/ForkPoolWorker-2] HTTP Request: GET https://youtube.com "HTTP/1.1 301 Moved Permanently"
|
||||
[2026-02-09 19:09:42,577: INFO/ForkPoolWorker-2] HTTP Request: GET https://www.youtube.com/ "HTTP/1.1 200 OK"
|
||||
[2026-02-09 19:09:42,874: INFO/MainProcess] Task core.tasks.generate_summary[3a3f1ae2-e049-44a0-8743-e0dd2392820f] received
|
||||
[2026-02-09 19:09:42,877: INFO/ForkPoolWorker-2] Task core.tasks.process_bookmark[21590aeb-6045-4341-8f97-80291d55e5bb] succeeded in 0.48919728299370036s: 'Processed bookmark 29'
|
||||
[2026-02-09 19:09:43,056: INFO/ForkPoolWorker-2] Generating summary/tags for bookmark 29...
|
||||
[2026-02-09 19:09:53,936: INFO/ForkPoolWorker-2] AI Raw Response for 29: {
|
||||
"summary": "The content is a YouTube footer listing navigation links to corporate and support resources — About, Press, Copyright, Contact, Creators, Advertise, Developers, Terms, Privacy, Policy & Safety, How YouTube works, and Test new features. It also includes a copyright notice attributing the site to Google LLC (2026)."
|
||||
}
|
||||
[2026-02-09 19:09:53,938: INFO/ForkPoolWorker-2] Decoded JSON for 29: summary=True, tags=[]
|
||||
[2026-02-09 19:09:53,962: INFO/ForkPoolWorker-2] Task core.tasks.generate_summary[3a3f1ae2-e049-44a0-8743-e0dd2392820f] succeeded in 11.077497629041318s: 'Generated summary for bookmark 29'
|
||||
[2026-02-09 19:20:12,359: INFO/MainProcess] Task core.tasks.process_bookmark[e19646b8-ea58-48a7-89e1-3d0429b9030a] received
|
||||
[2026-02-09 19:20:26,876: INFO/ForkPoolWorker-2] HTTP Request: GET https://instagram.com "HTTP/1.1 301 Moved Permanently"
|
||||
[2026-02-09 19:20:28,386: INFO/ForkPoolWorker-2] HTTP Request: GET https://www.instagram.com/ "HTTP/1.1 200 OK"
|
||||
[2026-02-09 19:20:39,598: INFO/MainProcess] Task core.tasks.generate_summary[cfcfea70-d5a2-4694-9467-45333ac44d3f] received
|
||||
[2026-02-09 19:20:40,610: INFO/ForkPoolWorker-2] Task core.tasks.process_bookmark[e19646b8-ea58-48a7-89e1-3d0429b9030a] succeeded in 24.209723198961s: 'Processed bookmark 30'
|
||||
[2026-02-09 19:20:51,094: INFO/ForkPoolWorker-1] Task core.tasks.generate_summary[cfcfea70-d5a2-4694-9467-45333ac44d3f] succeeded in 8.763452520011924s: None
|
||||
[2026-02-09 19:25:41,119: INFO/MainProcess] Task core.tasks.process_bookmark[085eedb9-d96e-4e98-9af3-d92dffa37cdd] received
|
||||
[2026-02-09 19:25:50,124: INFO/ForkPoolWorker-2] HTTP Request: GET https://facebook.com "HTTP/1.1 301 Moved Permanently"
|
||||
[2026-02-09 19:25:50,634: INFO/ForkPoolWorker-2] HTTP Request: GET https://www.facebook.com/ "HTTP/1.1 400 Bad Request"
|
||||
[2026-02-09 19:25:50,872: WARNING/ForkPoolWorker-2] Error fetching bookmark 31 (https://facebook.com): Client error '400 Bad Request' for url 'https://www.facebook.com/'
|
||||
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400. Trying base domain backup.
|
||||
[2026-02-09 19:25:50,873: ERROR/ForkPoolWorker-2] Error fetching base domain for bookmark 31: Client error '400 Bad Request' for url 'https://www.facebook.com/'
|
||||
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400
|
||||
[2026-02-09 19:25:51,375: INFO/MainProcess] Task core.tasks.generate_summary[c0e9c502-a19a-4381-8b35-14420e55c69c] received
|
||||
[2026-02-09 19:25:51,615: INFO/ForkPoolWorker-2] Task core.tasks.process_bookmark[085eedb9-d96e-4e98-9af3-d92dffa37cdd] succeeded in 7.494607769011054s: 'Processed bookmark 31'
|
||||
[2026-02-09 19:25:52,897: INFO/ForkPoolWorker-1] Generating summary/tags for bookmark 31...
|
||||
[2026-02-09 19:26:10,621: INFO/ForkPoolWorker-1] AI Raw Response for 31: {
|
||||
"summary": "The webpage content could not be retrieved from https://facebook.com or its base domain. The fetch failed, so no further analysis is available from the provided source."
|
||||
}
|
||||
[2026-02-09 19:26:10,625: INFO/ForkPoolWorker-1] Decoded JSON for 31: summary=True, tags=[]
|
||||
[2026-02-09 19:26:10,872: INFO/ForkPoolWorker-1] Task core.tasks.generate_summary[c0e9c502-a19a-4381-8b35-14420e55c69c] succeeded in 19.491886807023548s: 'Generated summary for bookmark 31'
|
||||
@ -1,6 +0,0 @@
|
||||
|
||||
try:
|
||||
from taggit.serializers import TagListSerializerField, TaggitSerializer
|
||||
print("Import SUCCESS")
|
||||
except ImportError as e:
|
||||
print(f"Import FAILED: {e}")
|
||||
@ -1,3 +0,0 @@
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ('celery_app',)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,20 +0,0 @@
|
||||
import os
|
||||
from celery import Celery
|
||||
|
||||
# Set the default Django settings module for the 'celery' program.
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
|
||||
app = Celery('config')
|
||||
|
||||
# Using a string here means the worker doesn't have to serialize
|
||||
# the configuration object to child processes.
|
||||
# - namespace='CELERY' means all celery-related configuration keys
|
||||
# should have a `CELERY_` prefix.
|
||||
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||
|
||||
# Load task modules from all registered Django app configs.
|
||||
app.autodiscover_tasks()
|
||||
|
||||
@app.task(bind=True, ignore_result=True)
|
||||
def debug_task(self):
|
||||
print(f'Request: {self.request!r}')
|
||||
@ -14,11 +14,6 @@ from pathlib import Path
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Monkeypatch for django-revproxy compatibility with newer Werkzeug
|
||||
import werkzeug.urls
|
||||
if not hasattr(werkzeug.urls, "QUOTE_SPACES_AS_PLUS"):
|
||||
werkzeug.urls.QUOTE_SPACES_AS_PLUS = "%2B"
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
load_dotenv(BASE_DIR.parent / ".env")
|
||||
|
||||
@ -60,10 +55,6 @@ INSTALLED_APPS = [
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'rest_framework',
|
||||
'taggit',
|
||||
'django_filters',
|
||||
'auditlog',
|
||||
'core',
|
||||
]
|
||||
|
||||
@ -73,7 +64,6 @@ MIDDLEWARE = [
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'auditlog.middleware.AuditlogMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
# Disable X-Frame-Options middleware to allow Flatlogic preview iframes.
|
||||
# 'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
@ -190,37 +180,3 @@ if EMAIL_USE_SSL:
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
# DRF Settings
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
],
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
'rest_framework.authentication.BasicAuthentication',
|
||||
'core.authentication.APITokenAuthentication',
|
||||
],
|
||||
'DEFAULT_FILTER_BACKENDS': (
|
||||
'django_filters.rest_framework.DjangoFilterBackend',
|
||||
),
|
||||
}
|
||||
|
||||
# Celery Settings
|
||||
CELERY_BROKER_URL = os.getenv('CELERY_BROKER_URL', 'redis://localhost:6379/0')
|
||||
CELERY_RESULT_BACKEND = os.getenv('CELERY_RESULT_BACKEND', 'redis://localhost:6379/0')
|
||||
CELERY_ACCEPT_CONTENT = ['json']
|
||||
CELERY_TASK_SERIALIZER = 'json'
|
||||
CELERY_RESULT_SERIALIZER = 'json'
|
||||
CELERY_TIMEZONE = TIME_ZONE
|
||||
|
||||
# Run tasks synchronously in development (no Redis required)
|
||||
CELERY_TASK_ALWAYS_EAGER = False
|
||||
CELERY_TASK_EAGER_PROPAGATES = True
|
||||
|
||||
# RevProxy Settings
|
||||
REVPROXY = {}
|
||||
|
||||
# Login/Logout Redirects
|
||||
LOGIN_REDIRECT_URL = 'home'
|
||||
LOGOUT_REDIRECT_URL = 'home'
|
||||
@ -1,13 +1,29 @@
|
||||
"""
|
||||
URL configuration for config project.
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/5.2/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include, re_path
|
||||
from django.contrib.auth import views as auth_views
|
||||
from revproxy.views import ProxyView
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
from django.urls import include, path
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('accounts/login/', auth_views.LoginView.as_view(), name='login'),
|
||||
path('accounts/logout/', auth_views.LogoutView.as_view(), name='logout'),
|
||||
re_path(r'^flower/(?P<path>.*)$', staff_member_required(ProxyView.as_view(upstream='http://127.0.0.1:5555/flower/'))),
|
||||
path('', include('core.urls')),
|
||||
path("admin/", admin.site.urls),
|
||||
path("", include("core.urls")),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets")
|
||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,37 +1,3 @@
|
||||
from django.contrib import admin
|
||||
from .models import Team, TeamMembership, Bookmark, BookmarkShare, Extraction, Summary, APIToken
|
||||
|
||||
class TeamMembershipInline(admin.TabularInline):
|
||||
model = TeamMembership
|
||||
extra = 1
|
||||
|
||||
@admin.register(Team)
|
||||
class TeamAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'created_at', 'updated_at')
|
||||
inlines = [TeamMembershipInline]
|
||||
|
||||
@admin.register(Bookmark)
|
||||
class BookmarkAdmin(admin.ModelAdmin):
|
||||
list_display = ('title', 'url', 'user', 'is_favorite', 'created_at')
|
||||
list_filter = ('is_favorite', 'created_at', 'user')
|
||||
search_fields = ('title', 'url', 'notes')
|
||||
|
||||
@admin.register(BookmarkShare)
|
||||
class BookmarkShareAdmin(admin.ModelAdmin):
|
||||
list_display = ('bookmark', 'team', 'shared_by', 'shared_at')
|
||||
list_filter = ('team', 'shared_at')
|
||||
|
||||
@admin.register(Extraction)
|
||||
class ExtractionAdmin(admin.ModelAdmin):
|
||||
list_display = ('bookmark', 'extracted_at')
|
||||
|
||||
@admin.register(Summary)
|
||||
class SummaryAdmin(admin.ModelAdmin):
|
||||
list_display = ('bookmark', 'generated_at')
|
||||
|
||||
@admin.register(APIToken)
|
||||
class APITokenAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'user', 'created_at', 'last_used_at', 'is_active')
|
||||
list_filter = ('is_active', 'created_at', 'last_used_at')
|
||||
search_fields = ('name', 'user__username', 'token')
|
||||
readonly_fields = ('token', 'created_at', 'last_used_at')
|
||||
# Register your models here.
|
||||
|
||||
@ -1,48 +0,0 @@
|
||||
from rest_framework import viewsets, permissions, filters
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django.db.models import Q
|
||||
from core.models import Bookmark, Team
|
||||
from core.serializers import BookmarkSerializer, BookmarkDetailSerializer, TeamSerializer
|
||||
from core.tasks import process_bookmark
|
||||
|
||||
class ApiStatusView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get(self, request):
|
||||
return Response({
|
||||
"status": "up",
|
||||
"authenticated": request.user.is_authenticated
|
||||
})
|
||||
|
||||
class BookmarkViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||
filterset_fields = ['is_favorite']
|
||||
search_fields = ['title', 'url', 'notes', 'extraction__content_text']
|
||||
ordering_fields = ['created_at', 'updated_at', 'title']
|
||||
ordering = ['-created_at']
|
||||
|
||||
def get_queryset(self):
|
||||
user_teams = self.request.user.teams.all()
|
||||
return Bookmark.objects.filter(
|
||||
Q(user=self.request.user) |
|
||||
Q(shares__team__in=user_teams)
|
||||
).distinct().select_related('extraction', 'summary')
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'retrieve':
|
||||
return BookmarkDetailSerializer
|
||||
return BookmarkSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
bookmark = serializer.save()
|
||||
process_bookmark.delay(bookmark.id)
|
||||
|
||||
class TeamViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
serializer_class = TeamSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.user.teams.all()
|
||||
@ -1,35 +0,0 @@
|
||||
from rest_framework.authentication import BaseAuthentication
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
from django.utils import timezone
|
||||
from .models import APIToken
|
||||
|
||||
class APITokenAuthentication(BaseAuthentication):
|
||||
def authenticate(self, request):
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if not auth_header:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Support "Bearer <token>" and "Token <token>"
|
||||
prefix, token = auth_header.split()
|
||||
if prefix.lower() not in ['bearer', 'token']:
|
||||
return None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
return self._authenticate_credentials(token)
|
||||
|
||||
def _authenticate_credentials(self, key):
|
||||
try:
|
||||
token = APIToken.objects.select_related('user').get(token=key, is_active=True)
|
||||
except APIToken.DoesNotExist:
|
||||
raise AuthenticationFailed('Invalid or inactive API token')
|
||||
|
||||
if not token.user.is_active:
|
||||
raise AuthenticationFailed('User is inactive or deleted')
|
||||
|
||||
# Update last used timestamp
|
||||
token.last_used_at = timezone.now()
|
||||
token.save(update_fields=['last_used_at'])
|
||||
|
||||
return (token.user, token)
|
||||
@ -1,94 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-04 16:55
|
||||
|
||||
import django.db.models.deletion
|
||||
import taggit.managers
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Team',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Bookmark',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('url', models.URLField(max_length=1000)),
|
||||
('title', models.CharField(blank=True, max_length=255)),
|
||||
('notes', models.TextField(blank=True)),
|
||||
('is_favorite', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('tags', taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookmarks', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Extraction',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('content_html', models.TextField(blank=True)),
|
||||
('content_text', models.TextField(blank=True)),
|
||||
('metadata', models.JSONField(blank=True, default=dict)),
|
||||
('extracted_at', models.DateTimeField(auto_now_add=True)),
|
||||
('bookmark', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='extraction', to='core.bookmark')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Summary',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('content', models.TextField()),
|
||||
('generated_at', models.DateTimeField(auto_now_add=True)),
|
||||
('bookmark', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='summary', to='core.bookmark')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TeamMembership',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.CharField(choices=[('OWNER', 'Owner'), ('ADMIN', 'Admin'), ('MEMBER', 'Member')], default='MEMBER', max_length=10)),
|
||||
('joined_at', models.DateTimeField(auto_now_add=True)),
|
||||
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.team')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('user', 'team')},
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='members',
|
||||
field=models.ManyToManyField(related_name='teams', through='core.TeamMembership', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='BookmarkShare',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('shared_at', models.DateTimeField(auto_now_add=True)),
|
||||
('bookmark', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shares', to='core.bookmark')),
|
||||
('shared_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shared_bookmarks', to='core.team')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('bookmark', 'team')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -1,28 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-09 18:35
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='APIToken',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('token', models.CharField(editable=False, max_length=64, unique=True)),
|
||||
('name', models.CharField(default='Default Token', help_text="A recognizable name for this token (e.g. 'Mobile App')", max_length=255)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('last_used_at', models.DateTimeField(blank=True, null=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
@ -1,91 +1,3 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
from taggit.managers import TaggableManager
|
||||
import secrets
|
||||
|
||||
class Team(models.Model):
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
members = models.ManyToManyField(User, through='TeamMembership', related_name='teams')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class TeamMembership(models.Model):
|
||||
ROLE_CHOICES = [
|
||||
('OWNER', 'Owner'),
|
||||
('ADMIN', 'Admin'),
|
||||
('MEMBER', 'Member'),
|
||||
]
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
team = models.ForeignKey(Team, on_delete=models.CASCADE)
|
||||
role = models.CharField(max_length=10, choices=ROLE_CHOICES, default='MEMBER')
|
||||
joined_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('user', 'team')
|
||||
|
||||
class Bookmark(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='bookmarks')
|
||||
url = models.URLField(max_length=1000)
|
||||
title = models.CharField(max_length=255, blank=True)
|
||||
notes = models.TextField(blank=True)
|
||||
is_favorite = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
tags = TaggableManager()
|
||||
|
||||
def __str__(self):
|
||||
return self.title or self.url
|
||||
|
||||
class BookmarkShare(models.Model):
|
||||
bookmark = models.ForeignKey(Bookmark, on_delete=models.CASCADE, related_name='shares')
|
||||
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name='shared_bookmarks')
|
||||
shared_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
shared_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('bookmark', 'team')
|
||||
|
||||
class Extraction(models.Model):
|
||||
bookmark = models.OneToOneField(Bookmark, on_delete=models.CASCADE, related_name='extraction')
|
||||
content_html = models.TextField(blank=True)
|
||||
content_text = models.TextField(blank=True)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
extracted_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"Extraction for {self.bookmark}"
|
||||
|
||||
class Summary(models.Model):
|
||||
bookmark = models.OneToOneField(Bookmark, on_delete=models.CASCADE, related_name='summary')
|
||||
content = models.TextField()
|
||||
generated_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"Summary for {self.bookmark}"
|
||||
|
||||
class APIToken(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='api_tokens')
|
||||
token = models.CharField(max_length=64, unique=True, editable=False)
|
||||
name = models.CharField(max_length=255, help_text="A recognizable name for this token (e.g. 'Mobile App')", default="Default Token")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
last_used_at = models.DateTimeField(null=True, blank=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.token:
|
||||
self.token = secrets.token_hex(20)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} - {self.user.username}"
|
||||
|
||||
from auditlog.registry import auditlog
|
||||
auditlog.register(Bookmark)
|
||||
auditlog.register(Team)
|
||||
auditlog.register(TeamMembership)
|
||||
auditlog.register(APIToken)
|
||||
# Create your models here.
|
||||
|
||||
@ -1,56 +0,0 @@
|
||||
from rest_framework import serializers
|
||||
from django.db import transaction
|
||||
from django.contrib.auth.models import User
|
||||
from core.models import Bookmark, Team, TeamMembership, BookmarkShare, Extraction, Summary
|
||||
from taggit.serializers import TagListSerializerField, TaggitSerializer
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'username', 'email']
|
||||
|
||||
class TeamSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = ['id', 'name', 'description', 'created_at']
|
||||
|
||||
class ExtractionSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Extraction
|
||||
fields = ['content_text', 'extracted_at']
|
||||
|
||||
class SummarySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Summary
|
||||
fields = ['content', 'generated_at']
|
||||
|
||||
class BookmarkSerializer(TaggitSerializer, serializers.ModelSerializer):
|
||||
tags = TagListSerializerField(required=False)
|
||||
summary = SummarySerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Bookmark
|
||||
fields = ['id', 'url', 'title', 'notes', 'is_favorite', 'tags', 'summary', 'created_at', 'updated_at']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
def create(self, validated_data):
|
||||
user = self.context['request'].user
|
||||
tags = validated_data.pop('tags', [])
|
||||
with transaction.atomic():
|
||||
bookmark = Bookmark.objects.create(user=user, **validated_data)
|
||||
bookmark.tags.set(tags)
|
||||
return bookmark
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
tags = validated_data.pop('tags', None)
|
||||
with transaction.atomic():
|
||||
instance = super().update(instance, validated_data)
|
||||
if tags is not None:
|
||||
instance.tags.set(tags)
|
||||
return instance
|
||||
|
||||
class BookmarkDetailSerializer(BookmarkSerializer):
|
||||
extraction = ExtractionSerializer(read_only=True)
|
||||
|
||||
class Meta(BookmarkSerializer.Meta):
|
||||
fields = BookmarkSerializer.Meta.fields + ['extraction']
|
||||
217
core/tasks.py
217
core/tasks.py
@ -1,217 +0,0 @@
|
||||
import httpx
|
||||
from celery import shared_task
|
||||
from django.utils import timezone
|
||||
from core.models import Bookmark, Extraction, Summary
|
||||
from ai.local_ai_api import LocalAIApi
|
||||
from bs4 import BeautifulSoup
|
||||
import html2text
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
from taggit.models import Tag
|
||||
import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
}
|
||||
|
||||
def get_base_url(url):
|
||||
parsed = urlparse(url)
|
||||
return f"{parsed.scheme}://{parsed.netloc}/"
|
||||
|
||||
@shared_task(bind=True, max_retries=3)
|
||||
def process_bookmark(self, bookmark_id):
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(id=bookmark_id)
|
||||
except Bookmark.DoesNotExist:
|
||||
return
|
||||
|
||||
html_content = ""
|
||||
status_code = None
|
||||
content_type = None
|
||||
used_backup = False
|
||||
|
||||
try:
|
||||
with httpx.Client(follow_redirects=True, timeout=20.0, headers=DEFAULT_HEADERS) as client:
|
||||
response = client.get(bookmark.url)
|
||||
response.raise_for_status()
|
||||
html_content = response.text
|
||||
status_code = response.status_code
|
||||
content_type = response.headers.get('content-type')
|
||||
|
||||
# If content is too small, maybe it's a redirect or anti-bot page
|
||||
if len(html_content) < 500:
|
||||
raise ValueError("Content too small, likely failed to scrape meaningful data.")
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning(f"Error fetching bookmark {bookmark_id} ({bookmark.url}): {exc}. Trying base domain backup.")
|
||||
try:
|
||||
base_url = get_base_url(bookmark.url)
|
||||
if base_url.rstrip('/') != bookmark.url.rstrip('/'):
|
||||
with httpx.Client(follow_redirects=True, timeout=20.0, headers=DEFAULT_HEADERS) as client:
|
||||
response = client.get(base_url)
|
||||
response.raise_for_status()
|
||||
html_content = response.text
|
||||
status_code = response.status_code
|
||||
content_type = response.headers.get('content-type')
|
||||
used_backup = True
|
||||
else:
|
||||
if not html_content:
|
||||
raise exc
|
||||
except Exception as base_exc:
|
||||
logger.error(f"Error fetching base domain for bookmark {bookmark_id}: {base_exc}")
|
||||
if not html_content:
|
||||
html_content = f"<html><body><p>Failed to retrieve content from {bookmark.url} and its base domain.</p></body></html>"
|
||||
status_code = status_code or 0
|
||||
|
||||
soup = BeautifulSoup(html_content, 'html.parser')
|
||||
|
||||
# Simple title extraction if not already set
|
||||
if not bookmark.title:
|
||||
title_tag = soup.find('title')
|
||||
if title_tag:
|
||||
bookmark.title = title_tag.string.strip()[:255]
|
||||
bookmark.save()
|
||||
|
||||
# Readability extraction
|
||||
h = html2text.HTML2Text()
|
||||
h.ignore_links = False
|
||||
h.ignore_images = True
|
||||
text_content = h.handle(html_content)
|
||||
|
||||
extraction, created = Extraction.objects.update_or_create(
|
||||
bookmark=bookmark,
|
||||
defaults={
|
||||
'content_html': html_content,
|
||||
'content_text': text_content,
|
||||
'metadata': {
|
||||
'status_code': status_code,
|
||||
'content_type': content_type,
|
||||
'used_backup': used_backup,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# AI Summary generation
|
||||
generate_summary.delay(bookmark_id)
|
||||
|
||||
return f"Processed bookmark {bookmark_id}"
|
||||
|
||||
@shared_task
|
||||
def generate_summary(bookmark_id):
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(id=bookmark_id)
|
||||
except Bookmark.DoesNotExist:
|
||||
return
|
||||
|
||||
try:
|
||||
extraction = bookmark.extraction
|
||||
except Extraction.DoesNotExist:
|
||||
Summary.objects.update_or_create(
|
||||
bookmark=bookmark,
|
||||
defaults={'content': "Content extraction failed or is still in progress. AI summary cannot be generated."}
|
||||
)
|
||||
return
|
||||
|
||||
content_to_summarize = extraction.content_text.strip()
|
||||
used_backup = extraction.metadata.get('used_backup', False)
|
||||
|
||||
if not content_to_summarize or len(content_to_summarize) < 50:
|
||||
Summary.objects.update_or_create(
|
||||
bookmark=bookmark,
|
||||
defaults={'content': f"Insufficient content extracted from {bookmark.url} to generate a meaningful AI summary."}
|
||||
)
|
||||
return
|
||||
|
||||
# Check if we should generate tags (only if bookmark has no tags)
|
||||
should_generate_tags = bookmark.tags.count() == 0
|
||||
existing_tags = list(Tag.objects.values_list('name', flat=True).distinct()[:50])
|
||||
existing_tags_str = ", ".join(existing_tags)
|
||||
|
||||
# Prepare prompt for AI
|
||||
system_prompt = "You are a helpful assistant that summarizes web content and suggests tags for researchers. Be concise and professional. Always return response in JSON format."
|
||||
|
||||
user_prompt = f"Analyze the following content from the webpage '{bookmark.title or bookmark.url}'.\n\n"
|
||||
user_prompt += "1. Provide a summary in 2-3 concise sentences.\n"
|
||||
|
||||
if should_generate_tags:
|
||||
user_prompt += "2. Suggest 3-5 short and concise tags for this content.\n"
|
||||
if existing_tags:
|
||||
user_prompt += f"Prioritize these existing tags if they match: {existing_tags_str}\n"
|
||||
|
||||
user_prompt += "\nReturn your response in valid JSON format:\n"
|
||||
user_prompt += "{\n \"summary\": \"your summary here\""
|
||||
if should_generate_tags:
|
||||
user_prompt += ",\n \"tags\": [\"tag1\", \"tag2\", \"tag3\"]\n"
|
||||
else:
|
||||
user_prompt += "\n"
|
||||
user_prompt += "}\n\n"
|
||||
user_prompt += f"Content:\n{content_to_summarize[:4000]}"
|
||||
|
||||
try:
|
||||
logger.info(f"Generating summary/tags for bookmark {bookmark_id}...")
|
||||
response = LocalAIApi.create_response({
|
||||
"input": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
# "response_format": {"type": "json_object"} # Some proxies might not like this
|
||||
})
|
||||
|
||||
summary_text = None
|
||||
suggested_tags = []
|
||||
|
||||
if response.get("success"):
|
||||
raw_text = LocalAIApi.extract_text(response)
|
||||
logger.info(f"AI Raw Response for {bookmark_id}: {raw_text}")
|
||||
data = LocalAIApi.decode_json_from_response(response)
|
||||
if data:
|
||||
summary_text = data.get("summary")
|
||||
suggested_tags = data.get("tags", [])
|
||||
logger.info(f"Decoded JSON for {bookmark_id}: summary={bool(summary_text)}, tags={suggested_tags}")
|
||||
else:
|
||||
logger.warning(f"JSON decoding failed for {bookmark_id}. Fallback to text.")
|
||||
summary_text = raw_text
|
||||
|
||||
if summary_text and len(summary_text.strip()) > 10:
|
||||
Summary.objects.update_or_create(
|
||||
bookmark=bookmark,
|
||||
defaults={'content': summary_text.strip()}
|
||||
)
|
||||
|
||||
# Add tags if we should
|
||||
if should_generate_tags and suggested_tags:
|
||||
# Limit to 5 tags and ensure they are strings
|
||||
valid_tags = [str(t)[:50] for t in suggested_tags if t][:5]
|
||||
if valid_tags:
|
||||
bookmark.tags.add(*valid_tags)
|
||||
logger.info(f"Successfully added tags {valid_tags} to bookmark {bookmark_id}")
|
||||
return f"Generated summary and tags for bookmark {bookmark_id}"
|
||||
|
||||
return f"Generated summary for bookmark {bookmark_id}"
|
||||
else:
|
||||
error_msg = response.get('error') or "Empty response from AI"
|
||||
logger.error(f"Failed to generate summary for bookmark {bookmark_id}: {error_msg}")
|
||||
|
||||
# Create a fallback summary to stop the spinner
|
||||
fallback_content = "AI summary could not be generated at this time. "
|
||||
if used_backup:
|
||||
fallback_content += "The original page was unreachable, and the home page content was insufficient for a summary."
|
||||
elif bookmark.title:
|
||||
fallback_content += f"The page appears to be titled '{bookmark.title}'."
|
||||
else:
|
||||
fallback_content += f"Please visit the link directly: {bookmark.url}"
|
||||
|
||||
Summary.objects.update_or_create(
|
||||
bookmark=bookmark,
|
||||
defaults={'content': fallback_content}
|
||||
)
|
||||
return f"Failed to generate summary for bookmark {bookmark_id}, created fallback."
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in generate_summary for bookmark {bookmark_id}: {e}")
|
||||
Summary.objects.update_or_create(
|
||||
bookmark=bookmark,
|
||||
defaults={'content': "An unexpected error occurred while generating the AI summary."}
|
||||
)
|
||||
return f"Error in generate_summary for bookmark {bookmark_id}"
|
||||
@ -1,173 +1,25 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Knowledge Base{% endblock %}</title>
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Bootstrap & Icons -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--primary-accent: #3b82f6;
|
||||
--secondary-accent: #10b981;
|
||||
--bg-light: #f3f4f6;
|
||||
--card-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-light);
|
||||
font-family: 'Inter', sans-serif;
|
||||
color: #1f2937;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: rgba(255, 255, 255, 0.8) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-weight: 700;
|
||||
color: #111827 !important;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #4b5563 !important;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1rem !important;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: rgba(0,0,0,0.05);
|
||||
color: #111827 !important;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-accent);
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
padding: 0.625rem 1.25rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.breadcrumb-item a {
|
||||
text-decoration: none;
|
||||
color: var(--primary-accent);
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Glassmorphism effect */
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.page-link {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.page-item.active .page-link {
|
||||
background-color: var(--primary-accent);
|
||||
border-color: var(--primary-accent);
|
||||
}
|
||||
</style>
|
||||
{% block extra_css %}{% endblock %}
|
||||
{% if project_description %}
|
||||
<meta name="description" content="{{ project_description }}">
|
||||
<meta property="og:description" content="{{ project_description }}">
|
||||
<meta property="twitter:description" content="{{ project_description }}">
|
||||
{% endif %}
|
||||
{% if project_image_url %}
|
||||
<meta property="og:image" content="{{ project_image_url }}">
|
||||
<meta property="twitter:image" content="{{ project_image_url }}">
|
||||
{% endif %}
|
||||
{% load static %}
|
||||
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg sticky-top mb-5">
|
||||
<div class="container">
|
||||
<a class="navbar-brand d-flex align-items-center" href="{% url 'home' %}">
|
||||
<div class="bg-primary text-white rounded-3 p-1 me-2 d-flex align-items-center justify-content-center" style="width: 32px; height: 32px;">
|
||||
<i class="bi bi-bookmark-star-fill fs-5"></i>
|
||||
</div>
|
||||
<span>KnoBase</span>
|
||||
</a>
|
||||
<button class="navbar-toggler border-0" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto align-items-center">
|
||||
{% if user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'home' %}">My Library</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'team-list' %}">Teams</a>
|
||||
</li>
|
||||
<li class="nav-item ms-lg-3">
|
||||
<a class="btn btn-primary rounded-pill px-4" href="{% url 'bookmark-add' %}">
|
||||
<i class="bi bi-plus-lg me-1"></i> Add
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown ms-lg-3">
|
||||
<a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown">
|
||||
<div class="bg-secondary text-white rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 32px; height: 32px;">
|
||||
{{ user.username|first|upper }}
|
||||
</div>
|
||||
{{ user.username }}
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow border-0 mt-2">
|
||||
{% if user.is_staff %}
|
||||
<li><a class="dropdown-item" href="/admin/">Admin Panel</a></li>
|
||||
<li><a class="dropdown-item" href="/flower/">Task Monitor</a></li>
|
||||
{% endif %}
|
||||
<li><a class="dropdown-item" href="{% url 'activity-log' %}">Activity Log</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<form method="post" action="{% url 'logout' %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="dropdown-item text-danger">Logout</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'login' %}">Login</a>
|
||||
</li>
|
||||
<li class="nav-item ms-lg-3">
|
||||
<a class="btn btn-primary rounded-pill px-4" href="{% url 'login' %}">Get Started</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container pb-5">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -1,94 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Activity Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h1 class="display-6 fw-bold text-primary">Activity Log</h1>
|
||||
<p class="text-muted">History of your actions and changes.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="ps-4">Time</th>
|
||||
<th>Action</th>
|
||||
<th>Resource</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in log_entries %}
|
||||
<tr>
|
||||
<td class="ps-4 text-nowrap text-muted small">
|
||||
{{ entry.timestamp|date:"M d, Y H:i" }}
|
||||
</td>
|
||||
<td>
|
||||
{% if entry.action == 0 %}
|
||||
<span class="badge bg-success bg-opacity-10 text-success rounded-pill">Created</span>
|
||||
{% elif entry.action == 1 %}
|
||||
<span class="badge bg-primary bg-opacity-10 text-primary rounded-pill">Updated</span>
|
||||
{% elif entry.action == 2 %}
|
||||
<span class="badge bg-danger bg-opacity-10 text-danger rounded-pill">Deleted</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary rounded-pill">Accessed</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="fw-medium">{{ entry.content_type.name|capfirst }}</span>
|
||||
<br>
|
||||
<small class="text-muted">{{ entry.object_repr }}</small>
|
||||
</td>
|
||||
<td class="small text-muted">
|
||||
<div style="max-width: 400px; overflow-wrap: break-word;">
|
||||
{% if entry.changes %}
|
||||
{{ entry.changes|truncatechars:100 }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-clock-history fs-1 d-block mb-3"></i>
|
||||
No activity recorded yet.
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if is_paginated %}
|
||||
<div class="mt-4">
|
||||
<nav aria-label="Page navigation">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link rounded-pill px-3 me-1" href="?page={{ page_obj.previous_page_number }}">Previous</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link border-0 bg-transparent">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
|
||||
</li>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link rounded-pill px-3 ms-1" href="?page={{ page_obj.next_page_number }}">Next</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@ -1,309 +0,0 @@
|
||||
{
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ bookmark.title|default:bookmark.url }} - Knowledge Base{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'home' %}">My Bookmarks</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">Details</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card p-4 mb-4">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<h1 class="h2" id="bookmark-title">{{ bookmark.title|default:bookmark.url }}</h1>
|
||||
{% if bookmark.user == request.user %}
|
||||
<div class="d-flex gap-2">
|
||||
<form action="{% url 'bookmark-regenerate' bookmark.pk %}" method="post">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm" title="Regenerate Summary and Content">
|
||||
<i class="bi bi-arrow-clockwise"></i> Regenerate
|
||||
</button>
|
||||
</form>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-three-dots"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item" href="{% url 'bookmark-edit' bookmark.pk %}">Edit Metadata</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<form action="{% url 'bookmark-delete' bookmark.pk %}" method="post" onsubmit="return confirm('Are you sure?');">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="dropdown-item text-danger">Delete Bookmark</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<p class="text-muted mb-4">
|
||||
<i class="bi bi-link-45deg"></i> <a href="{{ bookmark.url }}" target="_blank" class="text-break">{{ bookmark.url }}</a>
|
||||
</p>
|
||||
|
||||
{% if bookmark.notes %}
|
||||
<div class="mb-4">
|
||||
<h5 class="text-uppercase small fw-bold text-muted mb-2">My Notes</h5>
|
||||
<div class="p-3 bg-light rounded border-start border-4 border-primary">
|
||||
{{ bookmark.notes|linebreaks }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h5 class="text-uppercase small fw-bold text-muted mb-0">AI Summary</h5>
|
||||
<div id="summary-actions" class="{% if not bookmark.summary %}d-none{% endif %}">
|
||||
{% if bookmark.user == request.user %}
|
||||
<button class="btn btn-link btn-sm p-0 text-decoration-none" onclick="toggleEdit('summary')">Edit</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="summary-container">
|
||||
{% if bookmark.summary %}
|
||||
<div id="summary-display" class="p-3 border rounded shadow-sm bg-white">
|
||||
{{ bookmark.summary.content }}
|
||||
</div>
|
||||
{% if bookmark.user == request.user %}
|
||||
<div id="summary-edit" class="d-none">
|
||||
<form action="{% url 'summary-update' bookmark.pk %}" method="post">
|
||||
{% csrf_token %}
|
||||
<textarea name="content" class="form-control mb-2" rows="4">{{ bookmark.summary.content }}</textarea>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="toggleEdit('summary')">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div id="summary-loading" class="alert alert-light border text-center small py-3">
|
||||
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"></div>
|
||||
AI Summary is being generated...
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
{% for tag in bookmark.tags.all %}
|
||||
<span class="badge bg-light text-dark border p-2 me-1">#{{ tag.name }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="text-uppercase small fw-bold text-muted mb-0">Extracted Text Content</h5>
|
||||
<div id="extraction-actions" class="{% if not bookmark.extraction %}d-none{% endif %}">
|
||||
{% if bookmark.user == request.user %}
|
||||
<button class="btn btn-link btn-sm p-0 text-decoration-none" onclick="toggleEdit('extraction')">Edit</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="extraction-container">
|
||||
{% if bookmark.extraction %}
|
||||
<div id="extraction-display" class="extraction-content text-muted small" style="max-height: 500px; overflow-y: auto;">
|
||||
{{ bookmark.extraction.content_text|linebreaks }}
|
||||
</div>
|
||||
{% if bookmark.user == request.user %}
|
||||
<div id="extraction-edit" class="d-none">
|
||||
<form action="{% url 'extraction-update' bookmark.pk %}" method="post">
|
||||
{% csrf_token %}
|
||||
<textarea name="content_text" class="form-control mb-2" rows="15">{{ bookmark.extraction.content_text }}</textarea>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="toggleEdit('extraction')">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div id="extraction-loading" class="alert alert-light border text-center small py-3">
|
||||
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"></div>
|
||||
Content is being extracted...
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
{% if bookmark.user == request.user %}
|
||||
<div class="card p-4 mb-4">
|
||||
<h5 class="h6 mb-3">Share with Teams</h5>
|
||||
<div class="list-group list-group-flush">
|
||||
{% for team in user.teams.all %}
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||
<div>
|
||||
<div class="fw-bold">{{ team.name }}</div>
|
||||
<div class="small text-muted">{{ team.members.count }} members</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-primary share-toggle"
|
||||
data-url="{% url 'bookmark-share-toggle' bookmark.pk team.pk %}"
|
||||
data-team-id="{{ team.pk }}">
|
||||
{% with shared=False %}
|
||||
{% for share in bookmark.shares.all %}
|
||||
{% if share.team == team %}{% with shared=True %}Shared{% endwith %}{% endif %}
|
||||
{% endfor %}
|
||||
{% if not shared %}Share{% endif %}
|
||||
{% endwith %}
|
||||
</button>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="small text-muted mb-0">You are not a member of any teams. <a href="{% url 'team-list' %}">Explore teams</a>.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card p-4">
|
||||
<h5 class="h6 mb-3">Information</h5>
|
||||
<ul class="list-unstyled small mb-0">
|
||||
<li class="mb-2"><span class="text-muted">Saved on:</span> {{ bookmark.created_at|date:"F d, Y H:i" }}</li>
|
||||
<li class="mb-2"><span class="text-muted">Last updated:</span> {{ bookmark.updated_at|date:"F d, Y H:i" }}</li>
|
||||
<li><span class="text-muted">Saved by:</span> {{ bookmark.user.username }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function toggleEdit(type) {
|
||||
const display = document.getElementById(type + '-display');
|
||||
const edit = document.getElementById(type + '-edit');
|
||||
if (!display || !edit) return;
|
||||
|
||||
if (display.classList.contains('d-none')) {
|
||||
display.classList.remove('d-none');
|
||||
edit.classList.add('d-none');
|
||||
} else {
|
||||
display.classList.add('d-none');
|
||||
edit.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.share-toggle').forEach(button => {
|
||||
button.addEventListener('click', async function() {
|
||||
const url = this.getAttribute('data-url');
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': '{{ csrf_token }}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.shared) {
|
||||
this.textContent = 'Shared';
|
||||
this.classList.remove('btn-outline-primary');
|
||||
this.classList.add('btn-primary');
|
||||
} else {
|
||||
this.textContent = 'Share';
|
||||
this.classList.remove('btn-primary');
|
||||
this.classList.add('btn-outline-primary');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Polling for summary and extraction
|
||||
const bookmarkId = "{{ bookmark.id }}";
|
||||
const apiUrl = `/api/bookmarks/${bookmarkId}/`;
|
||||
let pollInterval;
|
||||
|
||||
async function checkStatus() {
|
||||
try {
|
||||
const response = await fetch(apiUrl);
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
let allDone = true;
|
||||
|
||||
// Check Summary
|
||||
if (data.summary && data.summary.content) {
|
||||
const container = document.getElementById('summary-container');
|
||||
const actions = document.getElementById('summary-actions');
|
||||
if (document.getElementById('summary-loading')) {
|
||||
container.innerHTML = `
|
||||
<div id="summary-display" class="p-3 border rounded shadow-sm bg-white">
|
||||
${data.summary.content}
|
||||
</div>
|
||||
{% if bookmark.user == request.user %}
|
||||
<div id="summary-edit" class="d-none">
|
||||
<form action="{% url 'summary-update' bookmark.pk %}" method="post">
|
||||
{% csrf_token %}
|
||||
<textarea name="content" class="form-control mb-2" rows="4">${data.summary.content}</textarea>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="toggleEdit('summary')">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
`;
|
||||
actions.classList.remove('d-none');
|
||||
}
|
||||
} else {
|
||||
allDone = false;
|
||||
}
|
||||
|
||||
// Check Extraction
|
||||
if (data.extraction && data.extraction.content_text) {
|
||||
const container = document.getElementById('extraction-container');
|
||||
const actions = document.getElementById('extraction-actions');
|
||||
if (document.getElementById('extraction-loading')) {
|
||||
// Convert newlines to <br> for simple display, or use linebreaks style
|
||||
const formattedContent = data.extraction.content_text.replace(/\n/g, '<br>');
|
||||
container.innerHTML = `
|
||||
<div id="extraction-display" class="extraction-content text-muted small" style="max-height: 500px; overflow-y: auto;">
|
||||
${formattedContent}
|
||||
</div>
|
||||
{% if bookmark.user == request.user %}
|
||||
<div id="extraction-edit" class="d-none">
|
||||
<form action="{% url 'extraction-update' bookmark.pk %}" method="post">
|
||||
{% csrf_token %}
|
||||
<textarea name="content_text" class="form-control mb-2" rows="15">${data.extraction.content_text}</textarea>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="toggleEdit('extraction')">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
`;
|
||||
actions.classList.remove('d-none');
|
||||
}
|
||||
} else {
|
||||
allDone = false;
|
||||
}
|
||||
|
||||
// Update Title if it was empty
|
||||
const titleEl = document.getElementById('bookmark-title');
|
||||
if (data.title && (titleEl.textContent === data.url || titleEl.textContent === '')) {
|
||||
titleEl.textContent = data.title;
|
||||
}
|
||||
|
||||
if (allDone) {
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling if anything is missing
|
||||
if (document.getElementById('summary-loading') || document.getElementById('extraction-loading')) {
|
||||
pollInterval = setInterval(checkStatus, 3000);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -1,104 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% if object %}Edit{% else %}Add{% endif %} Bookmark - Knowledge Base{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card p-4 shadow-sm border-0">
|
||||
<h2 class="mb-4">{% if object %}Edit{% else %}Add New{% endif %} Bookmark</h2>
|
||||
|
||||
<form id="bookmark-form" method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="id_url" class="form-label">URL</label>
|
||||
<input type="url" name="url" class="form-control form-control-lg" id="id_url" placeholder="https://example.com" value="{{ form.url.value|default:'' }}" required>
|
||||
{% if form.url.errors %}<div class="text-danger small mt-1">{{ form.url.errors }}</div>{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="id_title" class="form-label">Title (Optional)</label>
|
||||
<input type="text" name="title" class="form-control" id="id_title" placeholder="Leave blank to auto-extract" value="{{ form.title.value|default:'' }}">
|
||||
<div class="form-text">We'll try to fetch the title automatically if you leave this blank.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="id_notes" class="form-label">Notes</label>
|
||||
<textarea name="notes" class="form-control" id="id_notes" rows="3">{{ form.notes.value|default:'' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="tags_input" class="form-label">Tags</label>
|
||||
<input type="text" name="tags_input" class="form-control" id="tags_input" placeholder="research, ai, deep-learning" value="{% for tag in object.tags.all %}{{ tag.name }}{% if not forloop.last %}, {% endif %}{% endfor %}">
|
||||
<div class="form-text">Comma-separated tags.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="is_favorite" id="id_is_favorite" {% if form.is_favorite.value %}checked{% endif %}>
|
||||
<label class="form-check-label" for="id_is_favorite">Mark as Favorite</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<a href="{% url 'home' %}" class="btn btn-link text-muted">Cancel</a>
|
||||
<button type="submit" id="submit-btn" class="btn btn-primary px-5 py-2 rounded-pill">
|
||||
<span class="submit-text">{% if object %}Update{% else %}Save{% endif %} Bookmark</span>
|
||||
<span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.getElementById('bookmark-form').addEventListener('submit', async function(e) {
|
||||
// We only use AJAX for ADDING bookmarks to ensure "immediate" return
|
||||
{% if not object %}
|
||||
e.preventDefault();
|
||||
|
||||
const form = this;
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const submitText = submitBtn.querySelector('.submit-text');
|
||||
const spinner = submitBtn.querySelector('.spinner-border');
|
||||
|
||||
// Show loading state
|
||||
submitBtn.disabled = true;
|
||||
submitText.classList.add('d-none');
|
||||
spinner.classList.remove('d-none');
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const response = await fetch(window.location.href, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.status === 'success' && data.redirect_url) {
|
||||
window.location.href = data.redirect_url;
|
||||
} else {
|
||||
// If not success, re-submit normally to show Django errors
|
||||
form.submit();
|
||||
}
|
||||
} else if (response.redirected) {
|
||||
window.location.href = response.url;
|
||||
} else {
|
||||
form.submit();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error submitting form:', error);
|
||||
form.submit();
|
||||
}
|
||||
{% endif %}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -1,255 +1,145 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}My Bookmarks - Knowledge Base{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<!-- Sidebar -->
|
||||
<div class="col-md-3">
|
||||
<div class="card p-3 mb-4 shadow-sm border-0 bg-white rounded-3">
|
||||
<h5 class="h6 text-uppercase fw-bold text-muted mb-3">Filter by Tag</h5>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a href="{% url 'home' %}" class="badge {% if not request.GET.tag %}bg-primary{% else %}bg-light text-dark border{% endif %} text-decoration-none p-2">All</a>
|
||||
{% for tag in all_tags %}
|
||||
<a href="?tag={{ tag.name }}{% if request.GET.q %}&q={{ request.GET.q }}{% endif %}"
|
||||
class="badge {% if request.GET.tag == tag.name %}bg-primary{% else %}bg-light text-dark border{% endif %} text-decoration-none p-2">
|
||||
#{{ tag.name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-3 mb-4 shadow-sm border-0 bg-white rounded-3">
|
||||
<h5 class="h6 text-uppercase fw-bold text-muted mb-3">My Teams</h5>
|
||||
<div class="list-group list-group-flush small">
|
||||
{% for team in teams %}
|
||||
<a href="{% url 'team-detail' team.pk %}" class="list-group-item list-group-item-action border-0 px-0 d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-people me-2"></i>{{ team.name }}</span>
|
||||
</a>
|
||||
{% empty %}
|
||||
<p class="text-muted small mb-0">No teams yet. <a href="{% url 'team-list' %}">Explore</a></p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="col-md-9">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">{% if request.GET.tag %}Tag: #{{ request.GET.tag }}{% else %}My Bookmarks{% endif %}</h1>
|
||||
<a href="{% url 'bookmark-add' %}" class="btn btn-primary rounded-pill px-4">
|
||||
<i class="bi bi-plus-lg me-1"></i> New Bookmark
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form method="get" class="mb-4">
|
||||
<div class="input-group input-group-lg shadow-sm">
|
||||
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search text-muted"></i></span>
|
||||
<input type="text" name="q" class="form-control border-start-0 ps-0 {% if request.GET.q %}border-end-0{% endif %}" placeholder="Search by title, url, notes or content..." value="{{ request.GET.q|default:'' }}">
|
||||
{% if request.GET.q %}
|
||||
<a href="{% url 'home' %}{% if request.GET.tag %}?tag={{ request.GET.tag }}{% endif %}" class="input-group-text bg-white border-start-0 text-decoration-none" title="Clear search">
|
||||
<i class="bi bi-x-lg text-muted"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if request.GET.tag %}
|
||||
<input type="hidden" name="tag" value="{{ request.GET.tag }}">
|
||||
{% endif %}
|
||||
<button type="submit" class="btn btn-primary px-4">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4" id="bookmarks-container">
|
||||
{% for bookmark in bookmarks %}
|
||||
<div class="col bookmark-item" data-id="{{ bookmark.id }}">
|
||||
<div class="card h-100 border-0 shadow-sm hover-elevate overflow-hidden">
|
||||
<div class="card-body d-flex flex-column">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h5 class="card-title h6 fw-bold mb-0 text-truncate w-100" title="{{ bookmark.title|default:bookmark.url }}">
|
||||
<a href="{% url 'bookmark-detail' bookmark.pk %}" class="text-decoration-none text-dark bookmark-title-link stretched-link">
|
||||
{% if bookmark.title %}
|
||||
{{ bookmark.title|truncatechars:50 }}
|
||||
{% else %}
|
||||
{{ bookmark.url|truncatechars:50 }}
|
||||
{% endif %}
|
||||
</a>
|
||||
</h5>
|
||||
{% if bookmark.is_favorite %}
|
||||
<i class="bi bi-star-fill text-warning ms-2 flex-shrink-0" title="Favorite"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<small class="text-muted d-block text-truncate">
|
||||
<i class="bi bi-link-45deg me-1"></i>{{ bookmark.url }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 flex-grow-1">
|
||||
{% if bookmark.notes %}
|
||||
<p class="card-text small text-secondary mb-2">{{ bookmark.notes|truncatewords:20 }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="summary-preview">
|
||||
{% if bookmark.summary %}
|
||||
<p class="card-text small text-dark bg-light p-2 rounded border-start border-3 border-primary">
|
||||
{{ bookmark.summary.content|truncatewords:20 }}
|
||||
</p>
|
||||
{% else %}
|
||||
<div class="text-muted small loading-indicator">
|
||||
<div class="spinner-border spinner-border-sm text-primary me-1" role="status"></div>
|
||||
Generating...
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto">
|
||||
<div class="d-flex flex-wrap gap-1 mb-3" style="max-height: 50px; overflow: hidden;">
|
||||
{% for tag in bookmark.tags.all|slice:":3" %}
|
||||
<span class="badge bg-light text-muted border small">#{{ tag.name }}</span>
|
||||
{% endfor %}
|
||||
{% if bookmark.tags.all.count > 3 %}
|
||||
<span class="badge bg-light text-muted border small">+{{ bookmark.tags.all.count|add:"-3" }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center pt-2 border-top">
|
||||
<small class="text-muted" style="font-size: 0.75rem;">
|
||||
{{ bookmark.created_at|date:"M d, Y" }}
|
||||
</small>
|
||||
|
||||
<div class="dropdown position-static">
|
||||
<button class="btn btn-link text-muted p-0" type="button" data-bs-toggle="dropdown" style="z-index: 2; position: relative;">
|
||||
<i class="bi bi-three-dots"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow">
|
||||
<li><a class="dropdown-item" href="{% url 'bookmark-detail' bookmark.pk %}">View Details</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'bookmark-edit' bookmark.pk %}">Edit</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<form action="{% url 'bookmark-delete' bookmark.pk %}" method="post" style="display: inline;">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="dropdown-item text-danger" onclick="return confirm('Are you sure?')">Delete</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-12 text-center py-5">
|
||||
<div class="card border-0 shadow-sm p-5 bg-white">
|
||||
<div class="display-1 text-light mb-3"><i class="bi bi-bookmark-plus"></i></div>
|
||||
<h3 class="h4">No bookmarks found</h3>
|
||||
<p class="text-muted">Start building your knowledge base by adding your first bookmark.</p>
|
||||
<div class="mt-3">
|
||||
<a href="{% url 'bookmark-add' %}" class="btn btn-primary rounded-pill px-4">Add Bookmark</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if is_paginated %}
|
||||
<nav class="mt-5">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item"><a class="page-link rounded-circle me-2 border-0 shadow-sm" href="?page={{ page_obj.previous_page_number }}{% if request.GET.q %}&q={{ request.GET.q }}{% endif %}{% if request.GET.tag %}&tag={{ request.GET.tag }}{% endif %}"><i class="bi bi-chevron-left"></i></a></li>
|
||||
{% endif %}
|
||||
|
||||
{% for i in paginator.page_range %}
|
||||
{% if page_obj.number == i %}
|
||||
<li class="page-item active"><span class="page-link rounded-circle me-2 border-0 shadow-sm">{{ i }}</span></li>
|
||||
{% elif i > page_obj.number|add:'-3' and i < page_obj.number|add:'3' %}
|
||||
<li class="page-item"><a class="page-link rounded-circle me-2 border-0 shadow-sm text-dark" href="?page={{ i }}{% if request.GET.q %}&q={{ request.GET.q }}{% endif %}{% if request.GET.tag %}&tag={{ request.GET.tag }}{% endif %}">{{ i }}</a></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item"><a class="page-link rounded-circle border-0 shadow-sm" href="?page={{ page_obj.next_page_number }}{% if request.GET.q %}&q={{ request.GET.q }}{% endif %}{% if request.GET.tag %}&tag={{ request.GET.tag }}{% endif %}"><i class="bi bi-chevron-right"></i></a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% block title %}{{ project_name }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
.hover-elevate:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 20px rgba(0,0,0,0.08) !important;
|
||||
transition: all 0.3s ease;
|
||||
:root {
|
||||
--bg-color-start: #6a11cb;
|
||||
--bg-color-end: #2575fc;
|
||||
--text-color: #ffffff;
|
||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.bookmark-item .card {
|
||||
transition: all 0.3s ease;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
|
||||
animation: bg-pan 20s linear infinite;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@keyframes bg-pan {
|
||||
0% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 100% 100%;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg-color);
|
||||
border: 1px solid var(--card-border-color);
|
||||
border-radius: 16px;
|
||||
padding: 2.5rem 2rem;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
|
||||
font-weight: 700;
|
||||
margin: 0 0 1.2rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.loader {
|
||||
margin: 1.5rem auto;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.25);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.runtime code {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
footer {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const bookmarksToPoll = Array.from(document.querySelectorAll('.bookmark-item')).filter(item => {
|
||||
return item.querySelector('.loading-indicator') !== null;
|
||||
}).map(item => ({
|
||||
element: item,
|
||||
id: item.getAttribute('data-id'),
|
||||
retries: 0
|
||||
}));
|
||||
|
||||
if (bookmarksToPoll.length === 0) return;
|
||||
|
||||
const MAX_RETRIES = 60; // 3 minutes at 3s interval
|
||||
let pollInterval = setInterval(async function() {
|
||||
let stillLoading = false;
|
||||
|
||||
for (let i = 0; i < bookmarksToPoll.length; i++) {
|
||||
const item = bookmarksToPoll[i];
|
||||
const loadingIndicator = item.element.querySelector('.loading-indicator');
|
||||
if (!loadingIndicator) continue;
|
||||
|
||||
if (item.retries >= MAX_RETRIES) {
|
||||
loadingIndicator.innerHTML = '<span class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i> Summarization timed out.</span>';
|
||||
continue;
|
||||
}
|
||||
|
||||
item.retries++;
|
||||
try {
|
||||
const response = await fetch(`/api/bookmarks/${item.id}/`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Update title if it was changed from URL to actual title
|
||||
const titleLink = item.element.querySelector('.bookmark-title-link');
|
||||
if (data.title && (titleLink.textContent.includes('://') || titleLink.textContent.trim().length === 0)) {
|
||||
titleLink.textContent = data.title.length > 50 ? data.title.substring(0, 50) + '...' : data.title;
|
||||
}
|
||||
|
||||
if (data.summary && data.summary.content) {
|
||||
const summaryPreview = item.element.querySelector('.summary-preview');
|
||||
const truncatedSummary = data.summary.content.split(' ').slice(0, 20).join(' ') + (data.summary.content.split(' ').length > 20 ? '...' : '');
|
||||
summaryPreview.innerHTML = `<p class="card-text small text-dark bg-light p-2 rounded border-start border-3 border-primary">${truncatedSummary}</p>`;
|
||||
} else {
|
||||
stillLoading = true;
|
||||
}
|
||||
} else if (response.status === 404) {
|
||||
loadingIndicator.innerHTML = '<span class="text-danger">Bookmark not found.</span>';
|
||||
} else {
|
||||
stillLoading = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling bookmark:', item.id, error);
|
||||
stillLoading = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!stillLoading) {
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
}, 3000);
|
||||
});
|
||||
</script>
|
||||
{% block content %}
|
||||
<main>
|
||||
<div class="card">
|
||||
<h1>Analyzing your requirements and generating your app…</h1>
|
||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
||||
<span class="sr-only">Loading…</span>
|
||||
</div>
|
||||
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
|
||||
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
|
||||
<p class="runtime">
|
||||
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
|
||||
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
|
||||
</footer>
|
||||
{% endblock %}
|
||||
@ -1,56 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ team.name }} - Team Space{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'team-list' %}">Teams</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ team.name }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-12">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h1>{{ team.name }}</h1>
|
||||
<span class="badge bg-primary">{{ team.members.count }} Members</span>
|
||||
</div>
|
||||
<p class="text-muted">{{ team.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-4">Shared Bookmarks</h3>
|
||||
|
||||
<div class="row">
|
||||
{% for bookmark in shared_bookmarks %}
|
||||
<div class="col-md-12 mb-3">
|
||||
<div class="card p-3">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h5 class="card-title mb-1">
|
||||
<a href="{% url 'bookmark-detail' bookmark.pk %}" class="text-decoration-none">{{ bookmark.title|default:bookmark.url }}</a>
|
||||
</h5>
|
||||
<p class="text-muted small mb-2">{{ bookmark.url }}</p>
|
||||
<div class="mt-2">
|
||||
{% for tag in bookmark.tags.all %}
|
||||
<span class="badge bg-light text-dark border">{{ tag.name }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<span class="text-muted small d-block mb-1">Shared by {{ bookmark.shares.first.shared_by.username }}</span>
|
||||
<span class="text-muted small">{{ bookmark.shares.first.shared_at|date:"M d, Y" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-12 text-center py-5">
|
||||
<div class="alert alert-info">
|
||||
No bookmarks have been shared with this team yet.
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,33 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}My Teams - Knowledge Base{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-12">
|
||||
<h1>My Teams</h1>
|
||||
<p class="text-muted">Collaborate with your colleagues by sharing bookmarks in shared team spaces.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
{% for team in teams %}
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card h-100 p-4">
|
||||
<h5 class="card-title">{{ team.name }}</h5>
|
||||
<p class="card-text text-muted">{{ team.description|truncatewords:20 }}</p>
|
||||
<div class="mt-auto">
|
||||
<a href="{% url 'team-detail' team.pk %}" class="btn btn-outline-primary w-100">View Team Bookmarks</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-12 text-center py-5">
|
||||
<div class="card p-5 bg-light">
|
||||
<h3>No teams yet</h3>
|
||||
<p>You aren't a member of any teams yet. Teams allow you to share knowledge with others.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,46 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login - Knowledge Base{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-4">
|
||||
<div class="card p-4">
|
||||
<h2 class="text-center mb-4">Login</h2>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% for field in form %}
|
||||
<div class="mb-3">
|
||||
<label for="{{ field.id_for_label }}" class="form-label">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
{% if field.errors %}
|
||||
<div class="text-danger small">{{ field.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<button type="submit" class="btn btn-primary w-100">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: #212529;
|
||||
background-color: #fff;
|
||||
background-clip: padding-box;
|
||||
border: 1px solid #ced4da;
|
||||
appearance: none;
|
||||
border-radius: 0.375rem;
|
||||
transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@ -1,56 +1,3 @@
|
||||
from django.test import TestCase, Client
|
||||
from django.contrib.auth.models import User
|
||||
from django.urls import reverse
|
||||
from .models import Bookmark
|
||||
from django.test import TestCase
|
||||
|
||||
class BookmarkTagUpdateTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username='testuser', password='password')
|
||||
self.client = Client()
|
||||
self.client.login(username='testuser', password='password')
|
||||
self.bookmark = Bookmark.objects.create(
|
||||
user=self.user,
|
||||
url='https://example.com',
|
||||
title='Test Bookmark',
|
||||
notes='Test Notes'
|
||||
)
|
||||
self.bookmark.tags.add('initial')
|
||||
|
||||
def test_update_tags(self):
|
||||
url = reverse('bookmark-edit', args=[self.bookmark.pk])
|
||||
|
||||
data = {
|
||||
'url': 'https://example.com',
|
||||
'title': 'Updated Title',
|
||||
'notes': 'Updated Notes',
|
||||
'tags_input': 'updated, new-tag',
|
||||
# 'is_favorite': False # BooleanField handling in forms usually requires 'on' or missing.
|
||||
# If missing, it's false.
|
||||
}
|
||||
|
||||
response = self.client.post(url, data)
|
||||
|
||||
# Check for redirect (success)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
self.bookmark.refresh_from_db()
|
||||
self.assertEqual(self.bookmark.title, 'Updated Title')
|
||||
|
||||
tags = list(self.bookmark.tags.names())
|
||||
tags.sort()
|
||||
self.assertEqual(tags, ['new-tag', 'updated'])
|
||||
|
||||
def test_clear_tags(self):
|
||||
url = reverse('bookmark-edit', args=[self.bookmark.pk])
|
||||
data = {
|
||||
'url': 'https://example.com',
|
||||
'title': 'Updated Title',
|
||||
'notes': 'Updated Notes',
|
||||
'tags_input': '', # Empty tags
|
||||
}
|
||||
|
||||
response = self.client.post(url, data)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
self.bookmark.refresh_from_db()
|
||||
self.assertEqual(self.bookmark.tags.count(), 0)
|
||||
# Create your tests here.
|
||||
|
||||
33
core/urls.py
33
core/urls.py
@ -1,34 +1,7 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from core.api_views import BookmarkViewSet, TeamViewSet, ApiStatusView
|
||||
from core.views import (
|
||||
BookmarkListView, BookmarkCreateView, BookmarkDetailView,
|
||||
BookmarkUpdateView, BookmarkDeleteView,
|
||||
TeamListView, TeamDetailView, BookmarkShareToggleView,
|
||||
BookmarkRegenerateView, SummaryUpdateView, ExtractionUpdateView,
|
||||
UserActivityLogView
|
||||
)
|
||||
from django.urls import path
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'bookmarks', BookmarkViewSet, basename='api-bookmark')
|
||||
router.register(r'teams', TeamViewSet, basename='api-team')
|
||||
from .views import home
|
||||
|
||||
urlpatterns = [
|
||||
path("", BookmarkListView.as_view(), name="home"),
|
||||
path("bookmark/add/", BookmarkCreateView.as_view(), name="bookmark-add"),
|
||||
path("bookmark/<int:pk>/", BookmarkDetailView.as_view(), name="bookmark-detail"),
|
||||
path("bookmark/<int:pk>/edit/", BookmarkUpdateView.as_view(), name="bookmark-edit"),
|
||||
path("bookmark/<int:pk>/delete/", BookmarkDeleteView.as_view(), name="bookmark-delete"),
|
||||
path("bookmark/<int:pk>/regenerate/", BookmarkRegenerateView.as_view(), name="bookmark-regenerate"),
|
||||
path("bookmark/<int:pk>/summary/update/", SummaryUpdateView.as_view(), name="summary-update"),
|
||||
path("bookmark/<int:pk>/extraction/update/", ExtractionUpdateView.as_view(), name="extraction-update"),
|
||||
path("bookmark/<int:pk>/share/<int:team_id>/", BookmarkShareToggleView.as_view(), name="bookmark-share-toggle"),
|
||||
|
||||
path("teams/", TeamListView.as_view(), name="team-list"),
|
||||
path("teams/<int:pk>/", TeamDetailView.as_view(), name="team-detail"),
|
||||
|
||||
path("activity/", UserActivityLogView.as_view(), name="activity-log"),
|
||||
|
||||
path("api/status/", ApiStatusView.as_view(), name="api-status"),
|
||||
path("api/", include(router.urls)),
|
||||
path("", home, name="home"),
|
||||
]
|
||||
|
||||
217
core/views.py
217
core/views.py
@ -1,200 +1,25 @@
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.views import View
|
||||
from django.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.urls import reverse_lazy, reverse
|
||||
from django.db.models import Q
|
||||
from django.db import transaction
|
||||
from django.http import JsonResponse, HttpResponseRedirect
|
||||
from .models import Bookmark, Team, Extraction, BookmarkShare, Summary
|
||||
from .tasks import process_bookmark
|
||||
from auditlog.models import LogEntry
|
||||
import os
|
||||
import platform
|
||||
|
||||
class BookmarkListView(LoginRequiredMixin, ListView):
|
||||
model = Bookmark
|
||||
template_name = 'core/index.html'
|
||||
context_object_name = 'bookmarks'
|
||||
paginate_by = 20
|
||||
from django import get_version as django_version
|
||||
from django.shortcuts import render
|
||||
from django.utils import timezone
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = Bookmark.objects.filter(user=self.request.user).order_by('-created_at')
|
||||
|
||||
# Search filter
|
||||
query = self.request.GET.get('q')
|
||||
if query:
|
||||
queryset = queryset.filter(
|
||||
Q(title__icontains=query) |
|
||||
Q(url__icontains=query) |
|
||||
Q(notes__icontains=query) |
|
||||
Q(extraction__content_text__icontains=query)
|
||||
).distinct()
|
||||
def home(request):
|
||||
"""Render the landing screen with loader and environment details."""
|
||||
host_name = request.get_host().lower()
|
||||
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
|
||||
now = timezone.now()
|
||||
|
||||
# Tag filter
|
||||
tag = self.request.GET.get('tag')
|
||||
if tag:
|
||||
queryset = queryset.filter(tags__name__in=[tag])
|
||||
|
||||
return queryset
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
# Add all tags used by the user for a sidebar or filter list
|
||||
from taggit.models import Tag
|
||||
context['all_tags'] = Tag.objects.filter(bookmark__user=self.request.user).distinct()
|
||||
context['teams'] = self.request.user.teams.all()
|
||||
return context
|
||||
|
||||
class BookmarkCreateView(LoginRequiredMixin, CreateView):
|
||||
model = Bookmark
|
||||
fields = ['url', 'title', 'notes', 'is_favorite']
|
||||
template_name = 'core/bookmark_form.html'
|
||||
success_url = reverse_lazy('home')
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.user = self.request.user
|
||||
# Save first to get the object
|
||||
self.object = form.save()
|
||||
|
||||
# Handle tags if provided in a separate field or as a comma-separated string
|
||||
tags = self.request.POST.get('tags_input')
|
||||
if tags:
|
||||
self.object.tags.add(*[t.strip() for t in tags.split(',')])
|
||||
|
||||
# Trigger background task
|
||||
process_bookmark.delay(self.object.id)
|
||||
|
||||
if self.request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return JsonResponse({'status': 'success', 'redirect_url': str(self.success_url)})
|
||||
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
class BookmarkUpdateView(LoginRequiredMixin, UpdateView):
|
||||
model = Bookmark
|
||||
fields = ['url', 'title', 'notes', 'is_favorite']
|
||||
template_name = 'core/bookmark_form.html'
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('bookmark-detail', kwargs={'pk': self.object.pk})
|
||||
|
||||
def form_valid(self, form):
|
||||
# Handle tags update
|
||||
tags_input = self.request.POST.get('tags_input', '')
|
||||
|
||||
with transaction.atomic():
|
||||
self.object = form.save()
|
||||
|
||||
# Clear existing tags and set new ones
|
||||
self.object.tags.clear()
|
||||
if tags_input:
|
||||
tag_names = [t.strip() for t in tags_input.split(',') if t.strip()]
|
||||
if tag_names:
|
||||
self.object.tags.add(*tag_names)
|
||||
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
def get_queryset(self):
|
||||
return Bookmark.objects.filter(user=self.request.user)
|
||||
|
||||
class BookmarkDeleteView(LoginRequiredMixin, DeleteView):
|
||||
model = Bookmark
|
||||
success_url = reverse_lazy('home')
|
||||
|
||||
def get_queryset(self):
|
||||
return Bookmark.objects.filter(user=self.request.user)
|
||||
|
||||
class BookmarkDetailView(LoginRequiredMixin, DetailView):
|
||||
model = Bookmark
|
||||
template_name = 'core/bookmark_detail.html'
|
||||
context_object_name = 'bookmark'
|
||||
|
||||
def get_queryset(self):
|
||||
# Allow viewing if it's the user's bookmark OR shared with one of their teams
|
||||
user_teams = self.request.user.teams.all()
|
||||
return Bookmark.objects.filter(
|
||||
Q(user=self.request.user) |
|
||||
Q(shares__team__in=user_teams)
|
||||
).distinct()
|
||||
|
||||
class BookmarkRegenerateView(LoginRequiredMixin, View):
|
||||
def post(self, request, pk):
|
||||
bookmark = get_object_or_404(Bookmark, pk=pk, user=request.user)
|
||||
# Delete existing summary and extraction to force regeneration and show loading states
|
||||
if hasattr(bookmark, 'summary'):
|
||||
bookmark.summary.delete()
|
||||
if hasattr(bookmark, 'extraction'):
|
||||
bookmark.extraction.delete()
|
||||
|
||||
process_bookmark.delay(bookmark.id)
|
||||
return HttpResponseRedirect(reverse('bookmark-detail', args=[pk]))
|
||||
|
||||
class SummaryUpdateView(LoginRequiredMixin, View):
|
||||
def post(self, request, pk):
|
||||
bookmark = get_object_or_404(Bookmark, pk=pk, user=request.user)
|
||||
content = request.POST.get('content')
|
||||
if content:
|
||||
Summary.objects.update_or_create(
|
||||
bookmark=bookmark,
|
||||
defaults={'content': content}
|
||||
)
|
||||
return HttpResponseRedirect(reverse('bookmark-detail', args=[pk]))
|
||||
|
||||
class ExtractionUpdateView(LoginRequiredMixin, View):
|
||||
def post(self, request, pk):
|
||||
bookmark = get_object_or_404(Bookmark, pk=pk, user=request.user)
|
||||
content_text = request.POST.get('content_text')
|
||||
if content_text:
|
||||
Extraction.objects.update_or_create(
|
||||
bookmark=bookmark,
|
||||
defaults={'content_text': content_text}
|
||||
)
|
||||
return HttpResponseRedirect(reverse('bookmark-detail', args=[pk]))
|
||||
|
||||
class TeamListView(LoginRequiredMixin, ListView):
|
||||
model = Team
|
||||
template_name = 'core/team_list.html'
|
||||
context_object_name = 'teams'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.user.teams.all()
|
||||
|
||||
class TeamDetailView(LoginRequiredMixin, DetailView):
|
||||
model = Team
|
||||
template_name = 'core/team_detail.html'
|
||||
context_object_name = 'team'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.user.teams.all()
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
# Get bookmarks shared with this team
|
||||
context['shared_bookmarks'] = Bookmark.objects.filter(shares__team=self.object).order_by('-shares__shared_at')
|
||||
return context
|
||||
|
||||
class BookmarkShareToggleView(LoginRequiredMixin, View):
|
||||
def post(self, request, pk, team_id):
|
||||
bookmark = get_object_or_404(Bookmark, pk=pk, user=request.user)
|
||||
team = get_object_or_404(Team, pk=team_id, members=request.user)
|
||||
|
||||
share, created = BookmarkShare.objects.get_or_create(
|
||||
bookmark=bookmark,
|
||||
team=team,
|
||||
defaults={'shared_by': request.user}
|
||||
)
|
||||
|
||||
if not created:
|
||||
share.delete()
|
||||
shared = False
|
||||
else:
|
||||
shared = True
|
||||
|
||||
return JsonResponse({'shared': shared})
|
||||
|
||||
class UserActivityLogView(LoginRequiredMixin, ListView):
|
||||
model = LogEntry
|
||||
template_name = 'core/activity_log.html'
|
||||
context_object_name = 'log_entries'
|
||||
paginate_by = 20
|
||||
|
||||
def get_queryset(self):
|
||||
return LogEntry.objects.filter(actor=self.request.user).select_related('content_type', 'actor').order_by('-timestamp')
|
||||
context = {
|
||||
"project_name": "New Style",
|
||||
"agent_brand": agent_brand,
|
||||
"django_version": django_version(),
|
||||
"python_version": platform.python_version(),
|
||||
"current_time": now,
|
||||
"host_name": host_name,
|
||||
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
|
||||
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
|
||||
}
|
||||
return render(request, "core/index.html", context)
|
||||
|
||||
@ -1,63 +0,0 @@
|
||||
|
||||
import os
|
||||
import django
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
django.setup()
|
||||
|
||||
from django.test import Client
|
||||
from django.contrib.auth.models import User
|
||||
from core.models import Bookmark
|
||||
from django.urls import reverse
|
||||
|
||||
def run():
|
||||
# Setup
|
||||
username = 'debug_user_123'
|
||||
password = 'password'
|
||||
if User.objects.filter(username=username).exists():
|
||||
User.objects.get(username=username).delete()
|
||||
|
||||
user = User.objects.create_user(username=username, password=password)
|
||||
|
||||
bookmark = Bookmark.objects.create(
|
||||
user=user,
|
||||
url='https://debug.com',
|
||||
title='Debug Bookmark',
|
||||
notes='Debug Notes'
|
||||
)
|
||||
bookmark.tags.add('initial')
|
||||
|
||||
print(f"Initial tags: {list(bookmark.tags.names())}")
|
||||
|
||||
client = Client()
|
||||
client.login(username=username, password=password)
|
||||
|
||||
url = reverse('bookmark-edit', args=[bookmark.pk])
|
||||
|
||||
# Test Clearing Tags
|
||||
data = {
|
||||
'url': 'https://debug.com',
|
||||
'title': 'Updated Title',
|
||||
'notes': 'Updated Notes',
|
||||
'tags_input': '', # Empty string
|
||||
'is_favorite': False
|
||||
}
|
||||
|
||||
print(f"Posting to {url} with data: {data}")
|
||||
response = client.post(url, data, HTTP_HOST='127.0.0.1')
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
final_tags = list(bookmark.tags.names())
|
||||
print(f"Final tags: {final_tags}")
|
||||
|
||||
if final_tags == []:
|
||||
print("SUCCESS: Tags cleared correctly.")
|
||||
else:
|
||||
print("FAILURE: Tags NOT cleared correctly.")
|
||||
|
||||
# Cleanup
|
||||
bookmark.delete()
|
||||
user.delete()
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
@ -1,81 +0,0 @@
|
||||
import os
|
||||
import json
|
||||
import django
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
django.setup()
|
||||
|
||||
from django.test import Client
|
||||
from django.contrib.auth.models import User
|
||||
from core.models import Bookmark
|
||||
|
||||
def run():
|
||||
# Setup
|
||||
username = 'api_debug_user'
|
||||
password = 'password'
|
||||
if User.objects.filter(username=username).exists():
|
||||
User.objects.get(username=username).delete()
|
||||
|
||||
user = User.objects.create_user(username=username, password=password)
|
||||
|
||||
bookmark = Bookmark.objects.create(
|
||||
user=user,
|
||||
url='https://api-debug.com',
|
||||
title='API Debug Bookmark',
|
||||
notes='Debug Notes'
|
||||
)
|
||||
bookmark.tags.add('initial')
|
||||
|
||||
print(f"Initial tags: {list(bookmark.tags.names())}")
|
||||
|
||||
client = Client()
|
||||
client.login(username=username, password=password)
|
||||
|
||||
url = f'/api/bookmarks/{bookmark.id}/'
|
||||
|
||||
# Test 1: Update tags and title
|
||||
data = {
|
||||
'tags': ['new', 'tags'],
|
||||
'title': 'Updated Title'
|
||||
}
|
||||
|
||||
print(f"PATCHing to {url} with data: {data}")
|
||||
response = client.patch(
|
||||
url,
|
||||
json.dumps(data),
|
||||
content_type='application/json',
|
||||
HTTP_HOST='127.0.0.1'
|
||||
)
|
||||
|
||||
print(f"Response status: {response.status_code}")
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
final_tags = sorted(list(bookmark.tags.names()))
|
||||
final_title = bookmark.title
|
||||
print(f"Final tags: {final_tags}")
|
||||
print(f"Final title: {final_title}")
|
||||
|
||||
success = True
|
||||
if final_tags != ['new', 'tags']:
|
||||
print("FAILURE: API Tags NOT updated correctly.")
|
||||
success = False
|
||||
else:
|
||||
print("SUCCESS: API Tags updated correctly.")
|
||||
|
||||
if final_title != 'Updated Title':
|
||||
print("FAILURE: API Title NOT updated correctly.")
|
||||
success = False
|
||||
else:
|
||||
print("SUCCESS: API Title updated correctly.")
|
||||
|
||||
# Cleanup
|
||||
if success:
|
||||
print("All tests passed.")
|
||||
else:
|
||||
print("Some tests failed.")
|
||||
|
||||
bookmark.delete()
|
||||
user.delete()
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
@ -1,14 +1,3 @@
|
||||
Django==5.2.7
|
||||
mysqlclient==2.2.7
|
||||
python-dotenv==1.1.1
|
||||
djangorestframework==3.15.2
|
||||
beautifulsoup4==4.12.3
|
||||
html2text==2024.2.26
|
||||
httpx==0.27.2
|
||||
django-taggit==6.1.0
|
||||
celery==5.4.0
|
||||
redis==5.0.8
|
||||
django-filter==24.3
|
||||
flower==2.0.1
|
||||
django-revproxy==0.11.0
|
||||
django-auditlog==3.4.1
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user