|
# retoor <retoor@molodetz.nl>
|
|
|
|
import asyncio
|
|
import logging
|
|
import shutil
|
|
|
|
from devplacepy.config import FORK_STAGING_DIR
|
|
from devplacepy import project_files
|
|
from devplacepy.content import create_content_item
|
|
from devplacepy.database import get_table, record_fork, delete_fork_relations
|
|
from devplacepy.services.jobs.base import JobService
|
|
from devplacepy.utils import XP_PROJECT
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ForkService(JobService):
|
|
kind = "fork"
|
|
title = "Fork"
|
|
description = (
|
|
"Copies a project into a brand-new project owned by the forking user off the request "
|
|
"path: it creates the destination project, duplicates the whole virtual filesystem, and "
|
|
"records the source-to-fork relation. The resulting project is permanent; only the job "
|
|
"tracking row is swept on retention."
|
|
)
|
|
|
|
def __init__(self):
|
|
super().__init__(name="fork", interval_seconds=2)
|
|
|
|
async def process(self, job: dict) -> dict:
|
|
payload = job["payload"]
|
|
source_uid = payload.get("source_project_uid", "")
|
|
forked_by_uid = payload.get("forked_by_uid", "")
|
|
title = (payload.get("title") or "").strip()
|
|
|
|
source = get_table("projects").find_one(uid=source_uid)
|
|
if not source:
|
|
raise ValueError(f"source project not found: {source_uid}")
|
|
user = get_table("users").find_one(uid=forked_by_uid)
|
|
if not user:
|
|
raise ValueError(f"forking user not found: {forked_by_uid}")
|
|
if not title:
|
|
raise ValueError("a destination title is required")
|
|
|
|
new_uid, new_slug = create_content_item(
|
|
"projects",
|
|
"project",
|
|
user,
|
|
{
|
|
"title": title,
|
|
"description": source.get("description") or "",
|
|
"release_date": source.get("release_date") or None,
|
|
"demo_date": source.get("demo_date") or None,
|
|
"project_type": source.get("project_type") or "software",
|
|
"platforms": source.get("platforms") or "",
|
|
"status": source.get("status") or "In Development",
|
|
"is_private": 1 if source.get("is_private") else 0,
|
|
"read_only": 0,
|
|
},
|
|
title,
|
|
XP_PROJECT,
|
|
"First Project",
|
|
source.get("description") or "",
|
|
None,
|
|
)
|
|
|
|
from devplacepy.services.audit import record as audit
|
|
|
|
try:
|
|
item_count = await asyncio.to_thread(
|
|
self._copy_files, source_uid, new_uid, user, job["uid"]
|
|
)
|
|
record_fork(source_uid, new_uid, forked_by_uid)
|
|
except Exception:
|
|
self._rollback(new_uid)
|
|
audit.record_system(
|
|
"project.fork.failed",
|
|
actor_kind="user",
|
|
actor_uid=forked_by_uid,
|
|
actor_username=user.get("username"),
|
|
result="failure",
|
|
target_type="project",
|
|
target_uid=source_uid,
|
|
target_label=source.get("title"),
|
|
summary=f"fork of project {source.get('title')} by {user.get('username')} failed",
|
|
links=[audit.source("project", source_uid, source.get("title")), audit.job(job["uid"])],
|
|
)
|
|
raise
|
|
|
|
audit.record_system(
|
|
"project.fork.complete",
|
|
actor_kind="user",
|
|
actor_uid=forked_by_uid,
|
|
actor_username=user.get("username"),
|
|
target_type="project",
|
|
target_uid=new_uid,
|
|
target_label=title,
|
|
metadata={"item_count": item_count},
|
|
summary=f"fork of project {source.get('title')} by {user.get('username')} completed as {title}",
|
|
links=[
|
|
audit.source("project", source_uid, source.get("title")),
|
|
audit.destination("project", new_uid, title),
|
|
audit.job(job["uid"]),
|
|
],
|
|
)
|
|
return {
|
|
"project_uid": new_uid,
|
|
"project_url": f"/projects/{new_slug}",
|
|
"source_project_uid": source_uid,
|
|
"item_count": item_count,
|
|
}
|
|
|
|
def _copy_files(
|
|
self, source_uid: str, new_uid: str, user: dict, job_uid: str
|
|
) -> int:
|
|
staging = FORK_STAGING_DIR / job_uid
|
|
try:
|
|
project_files.export_to_dir(source_uid, "", staging)
|
|
return project_files.import_from_dir(
|
|
new_uid, staging, user, skip_names=set()
|
|
)
|
|
finally:
|
|
shutil.rmtree(staging, ignore_errors=True)
|
|
|
|
def _rollback(self, new_uid: str) -> None:
|
|
try:
|
|
project_files.delete_all_project_files(new_uid)
|
|
delete_fork_relations(new_uid)
|
|
get_table("projects").delete(uid=new_uid)
|
|
except Exception:
|
|
logger.exception("fork rollback failed for %s", new_uid)
|
|
|
|
def cleanup(self, job: dict) -> None:
|
|
shutil.rmtree(FORK_STAGING_DIR / job["uid"], ignore_errors=True)
|