diff --git a/apps/app/.eslintrc.json b/.eslintrc.json similarity index 100% rename from apps/app/.eslintrc.json rename to .eslintrc.json diff --git a/.github/workflows/test_runner.yml b/.github/workflows/test_runner.yml deleted file mode 100644 index 5780c9f63de..00000000000 --- a/.github/workflows/test_runner.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Plane Tests - -on: - push: - pull_request: - branches: - - master - -jobs: - test: - runs-on: ubuntu-latest - - services: - postgres: - image: postgres:latest - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: github_actions - ports: - - 5432:5432 - options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 - - redis: - image: redis:latest - env: - REDIS_HOST: localhost - REDIS_PORT: 6379 - ports: - - 6379:6379 - options: --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.9 - uses: actions/setup-python@v4 - with: - python-version: 3.9 - - name: psycopg2 prerequisites - run: sudo apt-get install libpq-dev - - name: Install dependencies - working-directory: ./apiserver - run: | - python -m pip install --upgrade pip - pip install -r requirements/test.txt - - name: Run Tests - working-directory: ./apiserver - env: - SECRET_KEY: ${{ secrets.SECRET_KEY }} - run: coverage run --source='.' manage.py test --settings=plane.settings.test diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000000..294dc1c0eb9 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +hello@plane.so. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000000..75ccb884c29 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,59 @@ +# Contributing to Plane + +Thank you for showing an interest in contributing to Plane! All kinds of contributions are valuable to us. In this guide, we will cover how you can quickly onboard and make your first contribution. + +## Submitting an issue + +Before submitting a new issue, please search the [issues](https://github.com/makeplane/plane/issues) tab. Maybe an issue or discussion already exists and might inform you of workarounds. Otherwise, you can give new informplaneation. + +While we want to fix all the [issues](https://github.com/makeplane/plane/issues), before fixing a bug we need to be able to reproduce and confirm it. Please provide us with a minimal reproduction scenario using a repository or [Gist](https://gist.github.com/). Having a live, reproducible scenario gives us the information without asking questions back & forth with additional questions like: + +- 3rd-party libraries being used and their versions +- a use-case that fails + +Without said minimal reproduction, we won't be able to investigate all [issues](https://github.com/makeplane/plane/issues), and the issue might not be resolved. + +You can open a new issue with this [issue form](https://github.com/makeplane/plane/issues/new). + +## Projects setup and Architecture + +### Requirements + +- Node.js version v16.18.0 +- Python version 3.8+ +- Postgres version v14 +- Redis version v6.2.7 +- pnpm version 7.22.0 + +### Setup the project + +The project is a monorepo, with backend api and frontend in a single repo. + +The backend is a django project which is kept inside apiserver + +## Missing a Feature? + +If a feature is missing, you can directly _request_ a new one [here](https://github.com/makeplane/plane/issues/new?assignees=&labels=feature&template=feature_request.yml&title=%F0%9F%9A%80+Feature%3A+). You also can do the same by choosing "🚀 Feature" when raising a [New Issue](https://github.com/makeplane/plane/issues/new/choose) on our GitHub Repository. +If you would like to _implement_ it, an issue with your proposal must be submitted first, to be sure that we can use it. Please consider the guidelines given below. + +## Coding guidelines + +To ensure consistency throughout the source code, please keep these rules in mind as you are working: + +- All features or bug fixes must be tested by one or more specs (unit-tests). +- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier. + +## Need help? Questions and suggestions + +Questions, suggestions, and thoughts are most welcome. We can also be reached in our [Discord Server](https://discord.com/invite/A92xrEGCge). + +## Ways to contribute + +- Try Plane Cloud and the self hosting platform and give feedback +- Add new integrations +- Help with open [issues](https://github.com/makeplane/plane/issues) or [create your own](https://github.com/makeplane/plane/issues/new/choose) +- Share your thoughts and suggestions with us +- Help create tutorials and blog posts +- Request a feature by submitting a proposal +- Report a bug +- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations. diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index abb8505cd70..00000000000 --- a/Dockerfile +++ /dev/null @@ -1,138 +0,0 @@ -FROM node:18-alpine AS builder -RUN apk add --no-cache libc6-compat -RUN apk update -# Set working directory -WORKDIR /app - -RUN apk add curl - -COPY ./apps ./apps -COPY ./package.json ./package.json -COPY ./.eslintrc.json ./.eslintrc.json -COPY ./yarn.lock ./yarn.lock - -RUN yarn global add turbo -RUN turbo prune --scope=app --docker - -# Add lockfile and package.json's of isolated subworkspace -FROM node:18-alpine AS installer - -RUN apk add --no-cache libc6-compat -RUN apk update -WORKDIR /app - -# First install the dependencies (as they change less often) -COPY .gitignore .gitignore -COPY --from=builder /app/out/json/ . -COPY --from=builder /app/out/yarn.lock ./yarn.lock -RUN yarn install - -# Build the project -COPY --from=builder /app/out/full/ . -COPY turbo.json turbo.json - -RUN yarn turbo run build --filter=app... - -FROM python:3.8.14-alpine3.16 AS runner - -ENV SECRET_KEY ${SECRET_KEY} -ENV DATABASE_URL ${DATABASE_URL} -ENV REDIS_URL ${REDIS_URL} -ENV EMAIL_HOST ${EMAIL_HOST} -ENV EMAIL_HOST_USER ${EMAIL_HOST_USER} -ENV EMAIL_HOST_PASSWORD ${EMAIL_HOST_PASSWORD} - -ENV AWS_REGION ${AWS_REGION} -ENV AWS_ACCESS_KEY_ID ${AWS_ACCESS_KEY_ID} -ENV AWS_SECRET_ACCESS_KEY ${AWS_SECRET_ACCESS_KEY} -ENV AWS_S3_BUCKET_NAME ${AWS_S3_BUCKET_NAME} - - -ENV SENTRY_DSN ${SENTRY_DSN} -ENV WEB_URL ${WEB_URL} - -ENV DISABLE_COLLECTSTATIC ${DISABLE_COLLECTSTATIC} - -ENV GITHUB_CLIENT_SECRET ${GITHUB_CLIENT_SECRET} -ENV NEXT_PUBLIC_GITHUB_ID ${NEXT_PUBLIC_GITHUB_ID} -ENV NEXT_PUBLIC_GOOGLE_CLIENTID ${NEXT_PUBLIC_GOOGLE_CLIENTID} -ENV NEXT_PUBLIC_API_BASE_URL ${NEXT_PUBLIC_API_BASE_URL} - -# Frontend - -RUN apk --update --no-cache add \ - "libpq~=14" \ - "libxslt~=1.1" \ - "nodejs-current~=18" \ - "xmlsec~=1.2" - -WORKDIR /app - -# Don't run production as root -RUN addgroup -S plane && \ - adduser -S captain -G plane - -USER captain - -COPY --from=installer /app/apps/app/next.config.js . -COPY --from=installer /app/apps/app/package.json . - -# Automatically leverage output traces to reduce image size -# https://nextjs.org/docs/advanced-features/output-file-tracing -COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone ./ -COPY --from=installer --chown=captain:plane /app/apps/app/.next/static ./apps/app/.next/static - -EXPOSE 3000 - -# Backend - -USER root - - -ENV PYTHONDONTWRITEBYTECODE 1 -ENV PYTHONUNBUFFERED 1 -ENV PIP_DISABLE_PIP_VERSION_CHECK=1 - - -COPY ./apiserver/requirements.txt ./ -COPY ./apiserver/requirements ./requirements -RUN apk add libffi-dev -RUN apk --update --no-cache --virtual .build-deps add \ - "bash~=5.1" \ - "g++~=11.2" \ - "gcc~=11.2" \ - "cargo~=1.60" \ - "git~=2" \ - "make~=4.3" \ - "postgresql13-dev~=13" \ - "libc-dev" \ - "linux-headers" \ - && \ - pip install -r requirements.txt --compile --no-cache-dir \ - && \ - apk del .build-deps - - -RUN chown captain.plane /app - -# Add in Django deps and generate Django's static files -COPY ./apiserver/manage.py manage.py -COPY ./apiserver/plane plane/ -COPY ./apiserver/templates templates/ - -COPY ./apiserver/gunicorn.config.py ./ -USER root -RUN apk --update --no-cache add "bash~=5.1" -COPY ./bin ./bin/ -USER captain - -# Expose container port and run entry point script -EXPOSE 8000 - -USER root - -RUN apk --update add supervisor - -ADD /supervisor /src/supervisor - -CMD ["supervisord","-c","/src/supervisor/service_script.conf"] \ No newline at end of file diff --git a/apiserver/.env.example b/apiserver/.env.example new file mode 100644 index 00000000000..0595770fa7a --- /dev/null +++ b/apiserver/.env.example @@ -0,0 +1,18 @@ +# Backend +SECRET_KEY="<-- django secret -->" +EMAIL_HOST="<-- email smtp -->" +EMAIL_HOST_USER="<-- email host user -->" +EMAIL_HOST_PASSWORD="<-- email host password -->" + +AWS_REGION="<-- aws region -->" +AWS_ACCESS_KEY_ID="<-- aws access key -->" +AWS_SECRET_ACCESS_KEY="<-- aws secret acess key -->" +AWS_S3_BUCKET_NAME="<-- aws s3 bucket name -->" + +SENTRY_DSN="<-- sentry dsn -->" +WEB_URL="<-- frontend web url -->" + +GITHUB_CLIENT_SECRET="<-- github secret -->" + +DISABLE_COLLECTSTATIC=1 +DOCKERIZED=0 //True if running docker compose else 0 diff --git a/apiserver/Dockerfile.api b/apiserver/Dockerfile.api index f2aa59e51c2..e39f91f6f67 100644 --- a/apiserver/Dockerfile.api +++ b/apiserver/Dockerfile.api @@ -48,6 +48,9 @@ COPY gunicorn.config.py ./ USER root RUN apk --update --no-cache add "bash~=5.1" COPY ./bin ./bin/ + +RUN chmod +x ./bin/channel-worker ./bin/takeoff ./bin/worker + USER captain # Expose container port and run entry point script diff --git a/apiserver/Procfile b/apiserver/Procfile index f5b7e7897ae..026a3f95342 100644 --- a/apiserver/Procfile +++ b/apiserver/Procfile @@ -1,2 +1,3 @@ -web: gunicorn plane.wsgi -k gthread --workers 8 --bind 0.0.0.0:$PORT --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile - -worker: python manage.py rqworker \ No newline at end of file +web: gunicorn -w 4 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:$PORT --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile - +worker: python manage.py rqworker +channel-worker: python manage.py runworker issue-activites \ No newline at end of file diff --git a/apiserver/back_migration.py b/apiserver/back_migration.py new file mode 100644 index 00000000000..98f1947532c --- /dev/null +++ b/apiserver/back_migration.py @@ -0,0 +1,42 @@ +# All the python scripts that are used for back migrations + +from plane.db.models import Issue, IssueComment + +# Update description and description html values for old descriptions +def update_description(): + try: + + issues = Issue.objects.all() + updated_issues = [] + + for issue in issues: + issue.description_html = f"

{issue.description}

" + issue.description_stripped = issue.description + updated_issues.append(issue) + + Issue.objects.bulk_update( + updated_issues, ["description_html", "description_stripped"], batch_size=100 + ) + print("Success") + except Exception as e: + print(e) + print("Failed") + + +def update_comments(): + try: + + issue_comments = IssueComment.objects.all() + updated_issue_comments = [] + + for issue_comment in issue_comments: + issue_comment.comment_html = f"

{issue_comment.comment_stripped}

" + updated_issue_comments.append(issue_comment) + + Issue.objects.bulk_update( + updated_issue_comments, ["comment_html"], batch_size=100 + ) + print("Success") + except Exception as e: + print(e) + print("Failed") diff --git a/apiserver/bin/channel-worker b/apiserver/bin/channel-worker new file mode 100755 index 00000000000..90ba63d5030 --- /dev/null +++ b/apiserver/bin/channel-worker @@ -0,0 +1,6 @@ +#!/bin/bash +set -e + +python manage.py wait_for_db +python manage.py migrate +python manage.py runworker issue-activites \ No newline at end of file diff --git a/apiserver/bin/takeoff b/apiserver/bin/takeoff index b76a9fd3b4c..8340f16c704 100755 --- a/apiserver/bin/takeoff +++ b/apiserver/bin/takeoff @@ -2,4 +2,4 @@ set -e python manage.py wait_for_db python manage.py migrate -exec gunicorn plane.wsgi -k gthread --workers 8 --bind 0.0.0.0:8000 --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile - +exec gunicorn -w 8 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --config gunicorn.config.py --max-requests 1200 --max-requests-jitter 1000 --access-logfile - diff --git a/apiserver/plane/api/consumers/__init__.py b/apiserver/plane/api/consumers/__init__.py new file mode 100644 index 00000000000..cbf41cdfaee --- /dev/null +++ b/apiserver/plane/api/consumers/__init__.py @@ -0,0 +1 @@ +from .issue_consumer import IssueConsumer \ No newline at end of file diff --git a/apiserver/plane/api/consumers/issue_consumer.py b/apiserver/plane/api/consumers/issue_consumer.py new file mode 100644 index 00000000000..38eb69967a7 --- /dev/null +++ b/apiserver/plane/api/consumers/issue_consumer.py @@ -0,0 +1,547 @@ +from channels.generic.websocket import SyncConsumer +import json +from plane.db.models import IssueActivity, Project, User, Issue, State, Label + + +class IssueConsumer(SyncConsumer): + + # Track Chnages in name + def track_name( + self, + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, + ): + if current_instance.get("name") != requested_data.get("name"): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=current_instance.get("name"), + new_value=requested_data.get("name"), + field="name", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated the start date to {requested_data.get('name')}", + ) + ) + + # Track changes in parent issue + def track_parent( + self, + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, + ): + if current_instance.get("parent") != requested_data.get("parent"): + + if requested_data.get("parent") == None: + old_parent = Issue.objects.get(pk=current_instance.get("parent")) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=f"{project.identifier}-{old_parent.sequence_id}", + new_value=None, + field="parent", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated the parent issue to None", + old_identifier=old_parent.id, + new_identifier=None, + ) + ) + else: + new_parent = Issue.objects.get(pk=requested_data.get("parent")) + old_parent = Issue.objects.filter( + pk=current_instance.get("parent") + ).first() + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=f"{project.identifier}-{old_parent.sequence_id}" + if old_parent is not None + else None, + new_value=f"{project.identifier}-{new_parent.sequence_id}", + field="parent", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated the parent issue to {new_parent.name}", + old_identifier=old_parent.id + if old_parent is not None + else None, + new_identifier=new_parent.id, + ) + ) + + # Track changes in priority + def track_priority( + self, + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, + ): + if current_instance.get("priority") != requested_data.get("priority"): + if requested_data.get("priority") == None: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=current_instance.get("parent"), + new_value=requested_data.get("parent"), + field="priority", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated the priority to None", + ) + ) + else: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=current_instance.get("priority"), + new_value=requested_data.get("priority"), + field="priority", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated the priority to {requested_data.get('priority')}", + ) + ) + + # Track chnages in state of the issue + def track_state( + self, + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, + ): + if current_instance.get("state") != requested_data.get("state"): + + new_state = State.objects.get(pk=requested_data.get("state", None)) + old_state = State.objects.get(pk=current_instance.get("state", None)) + + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=old_state.name, + new_value=new_state.name, + field="state", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated the state to {new_state.name}", + old_identifier=old_state.id, + new_identifier=new_state.id, + ) + ) + + # Track issue description + def track_description( + self, + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, + ): + if current_instance.get("description_html") != requested_data.get("description_html"): + + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=current_instance.get("description_html"), + new_value=requested_data.get("description_html"), + field="description", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated the description to {requested_data.get('description_html')}", + ) + ) + + # Track changes in issue target date + def track_target_date( + self, + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, + ): + if current_instance.get("target_date") != requested_data.get("target_date"): + if requested_data.get("target_date") == None: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=current_instance.get("target_date"), + new_value=requested_data.get("target_date"), + field="target_date", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated the target date to None", + ) + ) + else: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=current_instance.get("target_date"), + new_value=requested_data.get("target_date"), + field="target_date", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated the target date to {requested_data.get('target_date')}", + ) + ) + + # Track changes in issue start date + def track_start_date( + self, + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, + ): + if current_instance.get("start_date") != requested_data.get("start_date"): + if requested_data.get("start_date") == None: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=current_instance.get("start_date"), + new_value=requested_data.get("start_date"), + field="start_date", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated the start date to None", + ) + ) + else: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=current_instance.get("start_date"), + new_value=requested_data.get("start_date"), + field="start_date", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated the start date to {requested_data.get('start_date')}", + ) + ) + + # Track changes in issue labels + def track_labels( + self, + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, + ): + # Label Addition + if len(requested_data.get("labels_list")) > len(current_instance.get("labels")): + + for label in requested_data.get("labels_list"): + if label not in current_instance.get("labels"): + label = Label.objects.get(pk=label) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value="", + new_value=label.name, + field="labels", + project=project, + workspace=project.workspace, + comment=f"{actor.email} added label {label.name}", + new_identifier=label.id, + old_identifier=None, + ) + ) + + # Label Removal + if len(requested_data.get("labels_list")) < len(current_instance.get("labels")): + + for label in current_instance.get("labels"): + if label not in requested_data.get("labels_list"): + label = Label.objects.get(pk=label) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=label.name, + new_value="", + field="labels", + project=project, + workspace=project.workspace, + comment=f"{actor.email} removed label {label.name}", + old_identifier=label.id, + new_identifier=None, + ) + ) + + # Track changes in issue assignees + def track_assignees( + self, + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, + ): + + # Assignee Addition + if len(requested_data.get("assignees_list")) > len( + current_instance.get("assignees") + ): + + for assignee in requested_data.get("assignees_list"): + if assignee not in current_instance.get("assignees"): + assignee = User.objects.get(pk=assignee) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value="", + new_value=assignee.email, + field="assignees", + project=project, + workspace=project.workspace, + comment=f"{actor.email} added assignee {assignee.email}", + new_identifier=actor.id, + ) + ) + + # Assignee Removal + if len(requested_data.get("assignees_list")) < len( + current_instance.get("assignees") + ): + + for assignee in current_instance.get("assignees"): + if assignee not in requested_data.get("assignees_list"): + assignee = User.objects.get(pk=assignee) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=assignee.email, + new_value="", + field="assignee", + project=project, + workspace=project.workspace, + comment=f"{actor.email} removed assignee {assignee.email}", + old_identifier=actor.id, + ) + ) + + # Track changes in blocking issues + def track_blocks( + self, + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, + ): + if len(requested_data.get("blocks_list")) > len( + current_instance.get("blocked_issues") + ): + + for block in requested_data.get("blocks_list"): + if ( + len( + [ + blocked + for blocked in current_instance.get("blocked_issues") + if blocked.get("block") == block + ] + ) + == 0 + ): + issue = Issue.objects.get(pk=block) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value="", + new_value=f"{project.identifier}-{issue.sequence_id}", + field="blocks", + project=project, + workspace=project.workspace, + comment=f"{actor.email} added blocking issue {project.identifier}-{issue.sequence_id}", + new_identifier=issue.id, + ) + ) + + # Blocked Issue Removal + if len(requested_data.get("blocks_list")) < len( + current_instance.get("blocked_issues") + ): + + for blocked in current_instance.get("blocked_issues"): + if blocked.get("block") not in requested_data.get("blocks_list"): + issue = Issue.objects.get(pk=blocked.get("block")) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=f"{project.identifier}-{issue.sequence_id}", + new_value="", + field="blocks", + project=project, + workspace=project.workspace, + comment=f"{actor.email} removed blocking issue {project.identifier}-{issue.sequence_id}", + old_identifier=issue.id, + ) + ) + + # Track changes in blocked_by issues + def track_blockings( + self, + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, + ): + if len(requested_data.get("blockers_list")) > len( + current_instance.get("blocker_issues") + ): + + for block in requested_data.get("blockers_list"): + if ( + len( + [ + blocked + for blocked in current_instance.get("blocker_issues") + if blocked.get("blocked_by") == block + ] + ) + == 0 + ): + issue = Issue.objects.get(pk=block) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value="", + new_value=f"{project.identifier}-{issue.sequence_id}", + field="blocking", + project=project, + workspace=project.workspace, + comment=f"{actor.email} added blocked by issue {project.identifier}-{issue.sequence_id}", + new_identifier=issue.id, + ) + ) + + # Blocked Issue Removal + if len(requested_data.get("blockers_list")) < len( + current_instance.get("blocker_issues") + ): + + for blocked in current_instance.get("blocker_issues"): + if blocked.get("blocked_by") not in requested_data.get("blockers_list"): + issue = Issue.objects.get(pk=blocked.get("blocked_by")) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=f"{project.identifier}-{issue.sequence_id}", + new_value="", + field="blocking", + project=project, + workspace=project.workspace, + comment=f"{actor.email} removed blocked by issue {project.identifier}-{issue.sequence_id}", + old_identifier=issue.id, + ) + ) + + # Receive message from room group + def issue_activity(self, event): + + issue_activities = [] + # Remove event type: + event.pop("type") + + requested_data = json.loads(event.get("requested_data")) + current_instance = json.loads(event.get("current_instance")) + issue_id = event.get("issue_id") + actor_id = event.get("actor_id") + project_id = event.get("project_id") + + actor = User.objects.get(pk=actor_id) + + project = Project.objects.get(pk=project_id) + + ISSUE_ACTIVITY_MAPPER = { + "name": self.track_name, + "parent": self.track_parent, + "priority": self.track_priority, + "state": self.track_state, + "description": self.track_description, + "target_date": self.track_target_date, + "start_date": self.track_start_date, + "labels_list": self.track_labels, + "assignees_list": self.track_assignees, + "blocks_list": self.track_blocks, + "blockers_list": self.track_blockings, + } + + for key in requested_data: + func = ISSUE_ACTIVITY_MAPPER.get(key, None) + if func is not None: + func( + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, + ) + + # Save all the values to database + IssueActivity.objects.bulk_create(issue_activities) diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 06795fd772b..569eef88c71 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -47,6 +47,7 @@ class Meta: class IssueStateSerializer(BaseSerializer): state_detail = StateSerializer(read_only=True, source="state") + project_detail = ProjectSerializer(read_only=True, source="project") class Meta: model = Issue @@ -67,6 +68,8 @@ class IssueCreateSerializer(BaseSerializer): write_only=True, required=False, ) + + # List of issues that are blocking this issue blockers_list = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=Issue.objects.all()), write_only=True, @@ -77,6 +80,8 @@ class IssueCreateSerializer(BaseSerializer): write_only=True, required=False, ) + + # List of issues that are blocked by this issue blocks_list = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=Issue.objects.all()), write_only=True, @@ -421,10 +426,12 @@ class IssueSerializer(BaseSerializer): parent_detail = IssueFlatSerializer(read_only=True, source="parent") label_details = LabelSerializer(read_only=True, source="labels", many=True) assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) + # List of issues blocked by this issue blocked_issues = BlockedIssueSerializer(read_only=True, many=True) + # List of issues that block this issue blocker_issues = BlockerIssueSerializer(read_only=True, many=True) issue_cycle = IssueCycleDetailSerializer(read_only=True) - issue_module = IssueModuleDetailSerializer(read_only=True, many=True) + issue_module = IssueModuleDetailSerializer(read_only=True) class Meta: model = Issue diff --git a/apiserver/plane/api/serializers/module.py b/apiserver/plane/api/serializers/module.py index 989802c8b50..1d3748f8d6f 100644 --- a/apiserver/plane/api/serializers/module.py +++ b/apiserver/plane/api/serializers/module.py @@ -7,7 +7,13 @@ from .project import ProjectSerializer from .issue import IssueStateSerializer -from plane.db.models import User, Module, ModuleMember, ModuleIssue +from plane.db.models import User, Module, ModuleMember, ModuleIssue, ModuleLink + + +class LinkCreateSerializer(serializers.Serializer): + + url = serializers.CharField(required=True) + title = serializers.CharField(required=False) class ModuleWriteSerializer(BaseSerializer): @@ -17,6 +23,11 @@ class ModuleWriteSerializer(BaseSerializer): write_only=True, required=False, ) + links_list = serializers.ListField( + child=LinkCreateSerializer(), + write_only=True, + required=False, + ) class Meta: model = Module @@ -33,6 +44,7 @@ class Meta: def create(self, validated_data): members = validated_data.pop("members_list", None) + links = validated_data.pop("links_list", None) project = self.context["project"] @@ -55,11 +67,31 @@ def create(self, validated_data): ignore_conflicts=True, ) + if links is not None: + ModuleLink.objects.bulk_create( + [ + ModuleLink( + module=module, + project=project, + workspace=project.workspace, + created_by=module.created_by, + updated_by=module.updated_by, + title=link.get("title", None), + url=link.get("url", None), + ) + for link in links + ], + batch_size=10, + ignore_conflicts=True, + ) + return module def update(self, instance, validated_data): members = validated_data.pop("members_list", None) + links = validated_data.pop("links_list", None) + if members is not None: ModuleIssue.objects.filter(module=instance).delete() ModuleMember.objects.bulk_create( @@ -75,7 +107,26 @@ def update(self, instance, validated_data): for member in members ], batch_size=10, - ignore_conflicts=True + ignore_conflicts=True, + ) + + if links is not None: + ModuleLink.objects.filter(module=instance).delete() + ModuleLink.objects.bulk_create( + [ + ModuleLink( + module=instance, + project=instance.project, + workspace=instance.project.workspace, + created_by=instance.created_by, + updated_by=instance.updated_by, + title=link.get("title", None), + url=link.get("url", None), + ) + for link in links + ], + batch_size=10, + ignore_conflicts=True, ) return super().update(instance, validated_data) @@ -114,12 +165,30 @@ class Meta: ] +class ModuleLinkSerializer(BaseSerializer): + + created_by_detail = UserLiteSerializer(read_only=True, source="created_by") + + class Meta: + model = ModuleLink + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + class ModuleSerializer(BaseSerializer): project_detail = ProjectSerializer(read_only=True, source="project") lead_detail = UserLiteSerializer(read_only=True, source="lead") members_detail = UserLiteSerializer(read_only=True, many=True, source="members") - module_issues = ModuleIssueSerializer(read_only=True, many=True) + issue_module = ModuleIssueSerializer(read_only=True, many=True) + link_module = ModuleLinkSerializer(read_only=True, many=True) class Meta: model = Module @@ -131,4 +200,4 @@ class Meta: "updated_by", "created_at", "updated_at", - ] + ] \ No newline at end of file diff --git a/apiserver/plane/api/serializers/workspace.py b/apiserver/plane/api/serializers/workspace.py index 15f9b1dbf26..fe180bbd82b 100644 --- a/apiserver/plane/api/serializers/workspace.py +++ b/apiserver/plane/api/serializers/workspace.py @@ -18,18 +18,12 @@ class Meta: fields = "__all__" read_only_fields = [ "id", - "slug", "created_by", "updated_by", "created_at", "updated_at", "owner", ] - extra_kwargs = { - "slug": { - "required": False, - }, - } class WorkSpaceMemberSerializer(BaseSerializer): diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index b04f9dc3a39..872f5953f7c 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -58,6 +58,9 @@ UserLastProjectWithWorkspaceEndpoint, UserWorkSpaceIssues, ProjectMemberUserEndpoint, + WorkspaceMemberUserEndpoint, + WorkspaceMemberUserViewsEndpoint, + WorkSpaceAvailabilityCheckEndpoint, ) from plane.api.views.project import AddTeamToProjectEndpoint @@ -147,6 +150,11 @@ name="user-project-invitaions", ), ## Workspaces ## + path( + "workspace-slug-check/", + WorkSpaceAvailabilityCheckEndpoint.as_view(), + name="workspace-availability", + ), path( "workspaces/", WorkSpaceViewSet.as_view( @@ -234,6 +242,16 @@ UserLastProjectWithWorkspaceEndpoint.as_view(), name="workspace-project-details", ), + path( + "workspaces//workspace-members/me/", + WorkspaceMemberUserEndpoint.as_view(), + name="workspace-member-details", + ), + path( + "workspaces//workspace-views/", + WorkspaceMemberUserViewsEndpoint.as_view(), + name="workspace-member-details", + ), ## End Workspaces ## # Projects path( @@ -585,7 +603,7 @@ ## IssueProperty Ebd ## File Assets path( - "file-assets/", + "workspaces//file-assets/", FileAssetEndpoint.as_view(), name="File Assets", ), diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index b641d6e2ce2..3a0193f8a48 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -34,6 +34,8 @@ UserWorkspaceInvitationsEndpoint, UserWorkspaceInvitationEndpoint, UserLastProjectWithWorkspaceEndpoint, + WorkspaceMemberUserEndpoint, + WorkspaceMemberUserViewsEndpoint, ) from .state import StateViewSet from .shortcut import ShortCutViewSet diff --git a/apiserver/plane/api/views/asset.py b/apiserver/plane/api/views/asset.py index 8d462b0cb88..902ae7009a8 100644 --- a/apiserver/plane/api/views/asset.py +++ b/apiserver/plane/api/views/asset.py @@ -2,10 +2,11 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.parsers import MultiPartParser, FormParser +from sentry_sdk import capture_exception # Module imports from .base import BaseAPIView -from plane.db.models import FileAsset +from plane.db.models import FileAsset, Workspace from plane.api.serializers import FileAssetSerializer @@ -22,9 +23,23 @@ def get(self, request): serializer = FileAssetSerializer(files, context={"request": request}, many=True) return Response(serializer.data) - def post(self, request, *args, **kwargs): - serializer = FileAssetSerializer(data=request.data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + def post(self, request, slug): + try: + serializer = FileAssetSerializer(data=request.data) + if serializer.is_valid(): + + if request.user.last_workspace_id is None: + return Response( + {"error": "Workspace id is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer.save(workspace_id=request.user.last_workspace_id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/api/views/auth_extended.py b/apiserver/plane/api/views/auth_extended.py index 0e24e39d08a..487d10a2260 100644 --- a/apiserver/plane/api/views/auth_extended.py +++ b/apiserver/plane/api/views/auth_extended.py @@ -155,5 +155,5 @@ def post(self, request): capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) diff --git a/apiserver/plane/api/views/authentication.py b/apiserver/plane/api/views/authentication.py index ca8e2df604d..c77bdd1606b 100644 --- a/apiserver/plane/api/views/authentication.py +++ b/apiserver/plane/api/views/authentication.py @@ -288,7 +288,7 @@ def post(self, request): capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) @@ -361,5 +361,5 @@ def post(self, request): capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) diff --git a/apiserver/plane/api/views/base.py b/apiserver/plane/api/views/base.py index f74542aac51..5d8e75fb3c2 100644 --- a/apiserver/plane/api/views/base.py +++ b/apiserver/plane/api/views/base.py @@ -40,7 +40,7 @@ def get_queryset(self): except Exception as e: print(e) raise APIException( - "Please check the view", status.HTTP_500_INTERNAL_SERVER_ERROR + "Please check the view", status.HTTP_400_BAD_REQUEST ) def dispatch(self, request, *args, **kwargs): diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index aca7fb6410d..62c0376b3e0 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -113,5 +113,5 @@ def create(self, request, slug, project_id, cycle_id): capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 9dcbb272425..78f050af80b 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -1,14 +1,18 @@ # Python imports -from itertools import groupby +import json +from itertools import groupby, chain # Django imports from django.db.models import Prefetch from django.db.models import Count, Sum +from django.core.serializers.json import DjangoJSONEncoder # Third Party imports from rest_framework.response import Response from rest_framework import status from sentry_sdk import capture_exception +from channels.layers import get_channel_layer +from asgiref.sync import async_to_sync # Module imports from . import BaseViewSet, BaseAPIView @@ -37,7 +41,7 @@ Label, IssueBlocker, CycleIssue, - ModuleIssue + ModuleIssue, ) @@ -67,6 +71,28 @@ def get_serializer_class(self): def perform_create(self, serializer): serializer.save(project_id=self.kwargs.get("project_id")) + def perform_update(self, serializer): + requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) + current_instance = Issue.objects.filter(pk=self.kwargs.get("pk", None)).first() + if current_instance is not None: + + channel_layer = get_channel_layer() + async_to_sync(channel_layer.send)( + "issue-activites", + { + "type": "issue.activity", + "requested_data": requested_data, + "actor_id": str(self.request.user.id), + "issue_id": str(self.kwargs.get("pk", None)), + "project_id": str(self.kwargs.get("project_id", None)), + "current_instance": json.dumps( + IssueSerializer(current_instance).data, cls=DjangoJSONEncoder + ), + }, + ) + + return super().perform_update(serializer) + def get_queryset(self): return ( super() @@ -146,7 +172,7 @@ def list(self, request, slug, project_id): capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) def create(self, request, slug, project_id): @@ -158,6 +184,17 @@ def create(self, request, slug, project_id): if serializer.is_valid(): serializer.save() + + # Track the issue + IssueActivity.objects.create( + issue_id=serializer.data["id"], + project_id=project_id, + workspace_id=serializer["workspace"], + comment=f"{request.user.email} created the issue", + verb="created", + actor=request.user, + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -179,7 +216,7 @@ def get(self, request, slug): capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) @@ -200,23 +237,42 @@ def get(self, request, slug): capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) class IssueActivityEndpoint(BaseAPIView): + + permission_classes = [ + ProjectEntityPermission, + ] + def get(self, request, slug, project_id, issue_id): try: - issue_activities = IssueActivity.objects.filter(issue_id=issue_id).filter( - project__project_projectmember__member=self.request.user + issue_activities = ( + IssueActivity.objects.filter(issue_id=issue_id) + .filter(project__project_projectmember__member=self.request.user) + .select_related("actor") + ).order_by("created_by") + issue_comments = ( + IssueComment.objects.filter(issue_id=issue_id) + .filter(project__project_projectmember__member=self.request.user) + .order_by("created_at") ) - serializer = IssueActivitySerializer(issue_activities, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) + issue_activities = IssueActivitySerializer(issue_activities, many=True).data + issue_comments = IssueCommentSerializer(issue_comments, many=True).data + + result_list = sorted( + chain(issue_activities, issue_comments), + key=lambda instance: instance["created_at"], + ) + + return Response(result_list, status=status.HTTP_200_OK) except Exception as e: capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) @@ -224,6 +280,9 @@ class IssueCommentViewSet(BaseViewSet): serializer_class = IssueCommentSerializer model = IssueComment + permission_classes = [ + ProjectEntityPermission, + ] filterset_fields = [ "issue__id", @@ -343,7 +402,7 @@ def create(self, request, slug, project_id): capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) @@ -407,5 +466,5 @@ def delete(self, request, slug, project_id): capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index a2475b39053..a1aff67a0c7 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -15,7 +15,7 @@ ModuleIssueSerializer, ) from plane.api.permissions import ProjectEntityPermission -from plane.db.models import Module, ModuleIssue, Project, Issue +from plane.db.models import Module, ModuleIssue, Project, Issue, ModuleLink class ModuleViewSet(BaseViewSet): @@ -48,6 +48,12 @@ def get_queryset(self): queryset=ModuleIssue.objects.select_related("module", "issue"), ) ) + .prefetch_related( + Prefetch( + "link_module", + queryset=ModuleLink.objects.select_related("module"), + ) + ) ) def create(self, request, slug, project_id): @@ -76,7 +82,7 @@ def create(self, request, slug, project_id): capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) @@ -130,6 +136,9 @@ def create(self, request, slug, project_id, module_id): pk__in=issues, workspace__slug=slug, project_id=project_id ) + # Delete old records in order to maintain the database integrity + ModuleIssue.objects.filter(issue_id__in=issues).delete() + ModuleIssue.objects.bulk_create( [ ModuleIssue( @@ -154,5 +163,5 @@ def create(self, request, slug, project_id, module_id): capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) + status=status.HTTP_400_BAD_REQUEST, + ) \ No newline at end of file diff --git a/apiserver/plane/api/views/people.py b/apiserver/plane/api/views/people.py index 81118484cd2..b45176d1d39 100644 --- a/apiserver/plane/api/views/people.py +++ b/apiserver/plane/api/views/people.py @@ -7,11 +7,11 @@ # Module imports from plane.api.serializers import ( UserSerializer, + WorkSpaceSerializer, ) from plane.api.views.base import BaseViewSet, BaseAPIView -from plane.db.models import User - +from plane.db.models import User, Workspace class PeopleEndpoint(BaseAPIView): @@ -44,8 +44,8 @@ def get(self, request): except Exception as e: capture_exception(e) return Response( - {"message": "Something went wrong"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + {"message": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, ) @@ -56,6 +56,19 @@ class UserEndpoint(BaseViewSet): def get_object(self): return self.request.user + def retrieve(self, request): + try: + workspace = Workspace.objects.get(pk=request.user.last_workspace_id) + return Response( + {"user": UserSerializer(request.user).data, "slug": workspace.slug} + ) + except Workspace.DoesNotExist: + return Response({"user": UserSerializer(request.user).data, "slug": None}) + except Exception as e: + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) class UpdateUserOnBoardedEndpoint(BaseAPIView): diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 7799c0e691c..a3113a10a4a 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -143,7 +143,7 @@ def create(self, request, slug): except IntegrityError as e: if "already exists" in str(e): return Response( - {"identifier": "The project identifier is already taken"}, + {"name": "The project name is already taken"}, status=status.HTTP_410_GONE, ) except Workspace.DoesNotExist as e: @@ -159,7 +159,7 @@ def create(self, request, slug): capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) def partial_update(self, request, slug, pk=None): @@ -199,7 +199,7 @@ def partial_update(self, request, slug, pk=None): capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) @@ -280,7 +280,7 @@ def post(self, request, slug, project_id): capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) @@ -324,7 +324,7 @@ def create(self, request): capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) @@ -353,6 +353,11 @@ def get_queryset(self): class AddMemberToProjectEndpoint(BaseAPIView): + + permission_classes = [ + ProjectBasePermission, + ] + def post(self, request, slug, project_id): try: @@ -399,11 +404,16 @@ def post(self, request, slug, project_id): capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) class AddTeamToProjectEndpoint(BaseAPIView): + + permission_classes = [ + ProjectBasePermission, + ] + def post(self, request, slug, project_id): try: @@ -449,7 +459,7 @@ def post(self, request, slug, project_id): capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) @@ -517,7 +527,7 @@ def get(self, request, slug): capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) def delete(self, request, slug): @@ -545,7 +555,7 @@ def delete(self, request, slug): capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) @@ -590,7 +600,7 @@ def post(self, request, slug): capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) @@ -629,7 +639,7 @@ def post(self, request, slug, project_id): except Exception as e: return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) @@ -647,11 +657,11 @@ def get(self, request, slug, project_id): except ProjectMember.DoesNotExist: return Response( {"error": "User not a member of the project"}, - status=status.HTTP_404_NOT_FOUND, + status=status.HTTP_403_FORBIDDEN, ) except Exception as e: capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index 2a412ec769d..b9e3bc6dd5f 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -86,7 +86,7 @@ def create(self, request): except IntegrityError as e: if "already exists" in str(e): return Response( - {"name": "The workspace with the name already exists"}, + {"slug": "The workspace with the slug already exists"}, status=status.HTTP_410_GONE, ) except Exception as e: @@ -96,7 +96,7 @@ def create(self, request): "error": "Something went wrong please try again later", "identifier": None, }, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) @@ -128,34 +128,28 @@ def get(self, request): capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) class WorkSpaceAvailabilityCheckEndpoint(BaseAPIView): - - permission_classes = [ - AllowAny, - ] - def get(self, request): try: - name = request.GET.get("name", False) + slug = request.GET.get("slug", False) - if not name: + if not slug or slug == "": return Response( - {"error": "Workspace Name is required"}, + {"error": "Workspace Slug is required"}, status=status.HTTP_400_BAD_REQUEST, ) - workspace = Workspace.objects.filter(name=name).exists() - - return Response({"status": workspace}, status=status.HTTP_200_OK) + workspace = Workspace.objects.filter(slug=slug).exists() + return Response({"status": not workspace}, status=status.HTTP_200_OK) except Exception as e: capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) @@ -168,70 +162,92 @@ class InviteWorkspaceEndpoint(BaseAPIView): def post(self, request, slug): try: - email = request.data.get("email", False) - + emails = request.data.get("emails", False) # Check if email is provided - if not email: + if not emails or not len(emails): return Response( - {"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Emails are required"}, status=status.HTTP_400_BAD_REQUEST ) - validate_email(email) - # Check if user is already a member of workspace workspace = Workspace.objects.get(slug=slug) - if WorkspaceMember.objects.filter( - workspace_id=workspace.id, member__email=email - ).exists(): + # Check if user is already a member of workspace + workspace_members = WorkspaceMember.objects.filter( + workspace_id=workspace.id, + member__email__in=[email.get("email") for email in emails], + ) + + if len(workspace_members): return Response( - {"error": "User is already member of workspace"}, + { + "error": "Some users are already member of workspace", + "workspace_users": WorkSpaceMemberSerializer( + workspace_members, many=True + ).data, + }, status=status.HTTP_400_BAD_REQUEST, ) - token = jwt.encode( - {"email": email, "timestamp": datetime.now().timestamp()}, - settings.SECRET_KEY, - algorithm="HS256", - ) - - workspace_invitation_obj = WorkspaceMemberInvite.objects.create( - email=email.strip().lower(), - workspace_id=workspace.id, - token=token, - role=request.data.get("role", 10), + workspace_invitations = [] + for email in emails: + try: + validate_email(email.get("email")) + workspace_invitations.append( + WorkspaceMemberInvite( + email=email.get("email").strip().lower(), + workspace_id=workspace.id, + token=jwt.encode( + { + "email": email, + "timestamp": datetime.now().timestamp(), + }, + settings.SECRET_KEY, + algorithm="HS256", + ), + role=email.get("role", 10), + ) + ) + except ValidationError: + return Response( + { + "error": f"Invalid email - {email} provided a valid email address is required to send the invite" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + WorkspaceMemberInvite.objects.bulk_create( + workspace_invitations, batch_size=10, ignore_conflicts=True ) - domain = settings.WEB_URL - - workspace_invitation.delay( - email, workspace.id, token, domain, request.user.email - ) + workspace_invitations = WorkspaceMemberInvite.objects.filter( + email__in=[email.get("email") for email in emails] + ).select_related("workspace") + + for invitation in workspace_invitations: + workspace_invitation.delay( + invitation.email, + workspace.id, + invitation.token, + settings.WEB_URL, + request.user.email, + ) return Response( { - "message": "Email sent successfully", - "id": workspace_invitation_obj.id, + "message": "Emails sent successfully", }, status=status.HTTP_200_OK, ) - except ValidationError: - return Response( - { - "error": "Invalid email address provided a valid email address is required to send the invite" - }, - status=status.HTTP_400_BAD_REQUEST, - ) + except Workspace.DoesNotExist: return Response( {"error": "Workspace does not exists"}, status=status.HTTP_400_BAD_REQUEST, ) except Exception as e: - print(e) capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) @@ -261,6 +277,24 @@ def post(self, request, slug, pk): workspace_invite.save() if workspace_invite.accepted: + + # Check if the user created account after invitation + user = User.objects.filter(email=email).first() + + # If the user is present then create the workspace member + if user is not None: + WorkspaceMember.objects.create( + workspace=workspace_invite.workspace, + member=user, + role=workspace_invite.role, + ) + + user.last_workspace_id = workspace_invite.workspace.id + user.save() + + # Delete the invitation + workspace_invite.delete() + return Response( {"message": "Workspace Invitation Accepted"}, status=status.HTTP_200_OK, @@ -286,7 +320,7 @@ def post(self, request, slug, pk): capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) @@ -349,7 +383,7 @@ def create(self, request): capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) @@ -381,6 +415,9 @@ class TeamMemberViewSet(BaseViewSet): serializer_class = TeamSerializer model = Team + permission_classes = [ + WorkSpaceAdminPermission, + ] search_fields = [ "member__email", @@ -442,7 +479,7 @@ def create(self, request, slug): capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, + status=status.HTTP_400_BAD_REQUEST, ) @@ -506,5 +543,47 @@ def get(self, request): capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) \ No newline at end of file + status=status.HTTP_400_BAD_REQUEST, + ) + + +class WorkspaceMemberUserEndpoint(BaseAPIView): + def get(self, request, slug): + try: + workspace_member = WorkspaceMember.objects.get( + member=request.user, workspace__slug=slug + ) + serializer = WorkSpaceMemberSerializer(workspace_member) + return Response(serializer.data, status=status.HTTP_200_OK) + except (Workspace.DoesNotExist, WorkspaceMember.DoesNotExist): + return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class WorkspaceMemberUserViewsEndpoint(BaseAPIView): + def post(self, request, slug): + try: + + workspace_member = WorkspaceMember.objects.get( + workspace__slug=slug, member=request.user + ) + workspace_member.view_props = request.data.get("view_props", {}) + workspace_member.save() + + return Response(status=status.HTTP_200_OK) + except WorkspaceMember.DoesNotExist: + return Response( + {"error": "User not a member of workspace"}, + status=status.HTTP_403_FORBIDDEN, + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/asgi.py b/apiserver/plane/asgi.py new file mode 100644 index 00000000000..b5beb1df4a1 --- /dev/null +++ b/apiserver/plane/asgi.py @@ -0,0 +1,24 @@ +import os + +from channels.routing import ProtocolTypeRouter, ChannelNameRouter +from django.core.asgi import get_asgi_application + +django_asgi_app = get_asgi_application() + + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production") +# Initialize Django ASGI application early to ensure the AppRegistry +# is populated before importing code that may import ORM models. + +from plane.api.consumers import IssueConsumer + +application = ProtocolTypeRouter( + { + "http": get_asgi_application(), + "channel": ChannelNameRouter( + { + "issue-activites": IssueConsumer.as_asgi(), + } + ), + } +) diff --git a/apiserver/plane/bgtasks/magic_link_code_task.py b/apiserver/plane/bgtasks/magic_link_code_task.py index 5673140cd91..89239e87d8e 100644 --- a/apiserver/plane/bgtasks/magic_link_code_task.py +++ b/apiserver/plane/bgtasks/magic_link_code_task.py @@ -17,7 +17,7 @@ def magic_link(email, key, token, current_site): from_email_string = f"Team Plane " - subject = f"Login!" + subject = f"Login for Plane" context = {"magic_url": abs_url, "code": token} diff --git a/apiserver/plane/bgtasks/project_invitation_task.py b/apiserver/plane/bgtasks/project_invitation_task.py index 9b1649f1fdc..681438851f8 100644 --- a/apiserver/plane/bgtasks/project_invitation_task.py +++ b/apiserver/plane/bgtasks/project_invitation_task.py @@ -26,7 +26,7 @@ def project_invitation(email, project_id, token, current_site): from_email_string = f"Team Plane " - subject = f"Welcome {email}!" + subject = f"{project.created_by.first_name or project.created_by.email} invited you to join {project.name} on Plane" context = { "email": email, diff --git a/apiserver/plane/bgtasks/workspace_invitation_task.py b/apiserver/plane/bgtasks/workspace_invitation_task.py index b85a24a8419..7066355888e 100644 --- a/apiserver/plane/bgtasks/workspace_invitation_task.py +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -28,7 +28,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): from_email_string = f"Team Plane " - subject = f"Welcome {email}!" + subject = f"{invitor or email} invited you to join {workspace.name} on Plane" context = { "email": email, diff --git a/apiserver/plane/db/admin.py b/apiserver/plane/db/admin.py deleted file mode 100644 index 161ec9de66c..00000000000 --- a/apiserver/plane/db/admin.py +++ /dev/null @@ -1,35 +0,0 @@ -# from django.contrib import admin -# from plane.db.models import User -# from plane.db.models.workspace import Workspace, WorkspaceMember, WorkspaceMemberInvite -# from plane.db.models.project import Project, ProjectMember, ProjectMemberInvite -# from plane.db.models.cycle import Cycle, CycleIssue -# from plane.db.models.issue import ( -# Issue, -# IssueActivity, -# IssueComment, -# IssueProperty, -# TimelineIssue, -# ) -# from plane.db.models.shortcut import Shortcut -# from plane.db.models.state import State -# from plane.db.models.social_connection import SocialLoginConnection -# from plane.db.models.view import View - -# admin.site.register(User) -# admin.site.register(Workspace) -# admin.site.register(WorkspaceMember) -# admin.site.register(WorkspaceMemberInvite) -# admin.site.register(Project) -# admin.site.register(ProjectMember) -# admin.site.register(ProjectMemberInvite) -# admin.site.register(Cycle) -# admin.site.register(CycleIssue) -# admin.site.register(Issue) -# admin.site.register(IssueActivity) -# admin.site.register(IssueComment) -# admin.site.register(IssueProperty) -# admin.site.register(TimelineIssue) -# admin.site.register(Shortcut) -# admin.site.register(State) -# admin.site.register(SocialLoginConnection) -# admin.site.register(View) diff --git a/apiserver/plane/db/apps.py b/apiserver/plane/db/apps.py index 70e4445bebe..c2741bba3ce 100644 --- a/apiserver/plane/db/apps.py +++ b/apiserver/plane/db/apps.py @@ -5,48 +5,48 @@ class DbConfig(AppConfig): name = "plane.db" - def ready(self): - - post_save_changed.connect( - self.model_activity, - sender=self.get_model("Issue"), - ) - - def model_activity(self, sender, instance, changed_fields, **kwargs): - - verb = "created" if instance._state.adding else "changed" - - import inspect - - for frame_record in inspect.stack(): - if frame_record[3] == "get_response": - request = frame_record[0].f_locals["request"] - REQUEST_METHOD = request.method - - if REQUEST_METHOD == "POST": - - self.get_model("IssueActivity").objects.create( - issue=instance, project=instance.project, actor=instance.created_by - ) - - elif REQUEST_METHOD == "PATCH": - - try: - del changed_fields["updated_at"] - del changed_fields["updated_by"] - except KeyError as e: - pass - - for field_name, (old, new) in changed_fields.items(): - field = field_name - old_value = old - new_value = new - self.get_model("IssueActivity").objects.create( - issue=instance, - verb=verb, - field=field, - old_value=old_value, - new_value=new_value, - project=instance.project, - actor=instance.updated_by, - ) + # def ready(self): + + # post_save_changed.connect( + # self.model_activity, + # sender=self.get_model("Issue"), + # ) + + # def model_activity(self, sender, instance, changed_fields, **kwargs): + + # verb = "created" if instance._state.adding else "changed" + + # import inspect + + # for frame_record in inspect.stack(): + # if frame_record[3] == "get_response": + # request = frame_record[0].f_locals["request"] + # REQUEST_METHOD = request.method + + # if REQUEST_METHOD == "POST": + + # self.get_model("IssueActivity").objects.create( + # issue=instance, project=instance.project, actor=instance.created_by + # ) + + # elif REQUEST_METHOD == "PATCH": + + # try: + # del changed_fields["updated_at"] + # del changed_fields["updated_by"] + # except KeyError as e: + # pass + + # for field_name, (old, new) in changed_fields.items(): + # field = field_name + # old_value = old + # new_value = new + # self.get_model("IssueActivity").objects.create( + # issue=instance, + # verb=verb, + # field=field, + # old_value=old_value, + # new_value=new_value, + # project=instance.project, + # actor=instance.updated_by, + # ) diff --git a/apiserver/plane/db/migrations/0012_auto_20230104_0117.py b/apiserver/plane/db/migrations/0012_auto_20230104_0117.py new file mode 100644 index 00000000000..b1ff63fe1cc --- /dev/null +++ b/apiserver/plane/db/migrations/0012_auto_20230104_0117.py @@ -0,0 +1,172 @@ +# Generated by Django 3.2.16 on 2023-01-03 19:47 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0011_auto_20221222_2357'), + ] + + operations = [ + migrations.AddField( + model_name='issueactivity', + name='new_identifier', + field=models.UUIDField(null=True), + ), + migrations.AddField( + model_name='issueactivity', + name='old_identifier', + field=models.UUIDField(null=True), + ), + migrations.AlterField( + model_name='moduleissue', + name='issue', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='issue_module', to='db.issue'), + ), + migrations.AlterUniqueTogether( + name='moduleissue', + unique_together=set(), + ), + migrations.AlterModelTable( + name='cycle', + table='cycles', + ), + migrations.AlterModelTable( + name='cycleissue', + table='cycle_issues', + ), + migrations.AlterModelTable( + name='fileasset', + table='file_assets', + ), + migrations.AlterModelTable( + name='issue', + table='issues', + ), + migrations.AlterModelTable( + name='issueactivity', + table='issue_activities', + ), + migrations.AlterModelTable( + name='issueassignee', + table='issue_assignees', + ), + migrations.AlterModelTable( + name='issueblocker', + table='issue_blockers', + ), + migrations.AlterModelTable( + name='issuecomment', + table='issue_comments', + ), + migrations.AlterModelTable( + name='issuelabel', + table='issue_labels', + ), + migrations.AlterModelTable( + name='issueproperty', + table='issue_properties', + ), + migrations.AlterModelTable( + name='issuesequence', + table='issue_sequences', + ), + migrations.AlterModelTable( + name='label', + table='labels', + ), + migrations.AlterModelTable( + name='module', + table='modules', + ), + migrations.AlterModelTable( + name='modulemember', + table='module_members', + ), + migrations.AlterModelTable( + name='project', + table='projects', + ), + migrations.AlterModelTable( + name='projectidentifier', + table='project_identifiers', + ), + migrations.AlterModelTable( + name='projectmember', + table='project_members', + ), + migrations.AlterModelTable( + name='projectmemberinvite', + table='project_member_invites', + ), + migrations.AlterModelTable( + name='shortcut', + table='shortcuts', + ), + migrations.AlterModelTable( + name='socialloginconnection', + table='social_login_connections', + ), + migrations.AlterModelTable( + name='state', + table='states', + ), + migrations.AlterModelTable( + name='team', + table='teams', + ), + migrations.AlterModelTable( + name='teammember', + table='team_members', + ), + migrations.AlterModelTable( + name='timelineissue', + table='issue_timelines', + ), + migrations.AlterModelTable( + name='user', + table='users', + ), + migrations.AlterModelTable( + name='view', + table='views', + ), + migrations.AlterModelTable( + name='workspace', + table='workspaces', + ), + migrations.AlterModelTable( + name='workspacemember', + table='workspace_members', + ), + migrations.AlterModelTable( + name='workspacememberinvite', + table='workspace_member_invites', + ), + migrations.CreateModel( + name='ModuleLink', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('title', models.CharField(max_length=255, null=True)), + ('url', models.URLField()), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modulelink_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('module', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='link_module', to='db.module')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_modulelink', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modulelink_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_modulelink', to='db.workspace')), + ], + options={ + 'verbose_name': 'Module Link', + 'verbose_name_plural': 'Module Links', + 'db_table': 'module_links', + 'ordering': ('-created_at',), + }, + ), + ] diff --git a/apiserver/plane/db/migrations/0013_auto_20230107_0041.py b/apiserver/plane/db/migrations/0013_auto_20230107_0041.py new file mode 100644 index 00000000000..c75537fc1bf --- /dev/null +++ b/apiserver/plane/db/migrations/0013_auto_20230107_0041.py @@ -0,0 +1,38 @@ +# Generated by Django 3.2.16 on 2023-01-06 19:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0012_auto_20230104_0117'), + ] + + operations = [ + migrations.AddField( + model_name='issue', + name='description_html', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='issue', + name='description_stripped', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='user', + name='role', + field=models.CharField(blank=True, max_length=300, null=True), + ), + migrations.AddField( + model_name='workspacemember', + name='view_props', + field=models.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name='issue', + name='description', + field=models.JSONField(blank=True), + ), + ] diff --git a/apiserver/plane/db/migrations/0014_alter_workspacememberinvite_unique_together.py b/apiserver/plane/db/migrations/0014_alter_workspacememberinvite_unique_together.py new file mode 100644 index 00000000000..b1786c9c169 --- /dev/null +++ b/apiserver/plane/db/migrations/0014_alter_workspacememberinvite_unique_together.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.16 on 2023-01-07 05:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0013_auto_20230107_0041'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='workspacememberinvite', + unique_together={('email', 'workspace')}, + ), + ] diff --git a/apiserver/plane/db/migrations/0015_auto_20230107_1636.py b/apiserver/plane/db/migrations/0015_auto_20230107_1636.py new file mode 100644 index 00000000000..e3f5dc26a2b --- /dev/null +++ b/apiserver/plane/db/migrations/0015_auto_20230107_1636.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.16 on 2023-01-07 11:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0014_alter_workspacememberinvite_unique_together'), + ] + + operations = [ + migrations.RenameField( + model_name='issuecomment', + old_name='comment', + new_name='comment_stripped', + ), + migrations.AddField( + model_name='issuecomment', + name='comment_html', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='issuecomment', + name='comment_json', + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/apiserver/plane/db/migrations/0016_auto_20230107_1735.py b/apiserver/plane/db/migrations/0016_auto_20230107_1735.py new file mode 100644 index 00000000000..073c1e11710 --- /dev/null +++ b/apiserver/plane/db/migrations/0016_auto_20230107_1735.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.16 on 2023-01-07 12:05 + +from django.db import migrations, models +import django.db.models.deletion +import plane.db.models.asset + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0015_auto_20230107_1636'), + ] + + operations = [ + migrations.AddField( + model_name='fileasset', + name='workspace', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='assets', to='db.workspace'), + ), + migrations.AlterField( + model_name='fileasset', + name='asset', + field=models.FileField(upload_to=plane.db.models.asset.get_upload_path, validators=[plane.db.models.asset.file_size]), + ), + ] diff --git a/apiserver/plane/db/migrations/0017_alter_workspace_unique_together.py b/apiserver/plane/db/migrations/0017_alter_workspace_unique_together.py new file mode 100644 index 00000000000..c6bfc21458e --- /dev/null +++ b/apiserver/plane/db/migrations/0017_alter_workspace_unique_together.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.16 on 2023-01-07 17:49 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0016_auto_20230107_1735'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='workspace', + unique_together=set(), + ), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 38091aa86e5..b6a19b67aaf 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -37,4 +37,4 @@ from .view import View -from .module import Module, ModuleMember, ModuleIssue +from .module import Module, ModuleMember, ModuleIssue, ModuleLink diff --git a/apiserver/plane/db/models/asset.py b/apiserver/plane/db/models/asset.py index 2df1dee213c..ff33dc9e0ba 100644 --- a/apiserver/plane/db/models/asset.py +++ b/apiserver/plane/db/models/asset.py @@ -1,24 +1,41 @@ # Django import from django.db import models +from django.core.exceptions import ValidationError # Module import from . import BaseModel +def get_upload_path(instance, filename): + return f"{instance.workspace.id}/{filename}" + + +def file_size(value): + limit = 5 * 1024 * 1024 + if value.size > limit: + raise ValidationError("File too large. Size should not exceed 5 MB.") + class FileAsset(BaseModel): """ A file asset. """ attributes = models.JSONField(default=dict) - asset = models.FileField(upload_to="library-assets") + asset = models.FileField( + upload_to=get_upload_path, + validators=[ + file_size, + ], + ) + workspace = models.ForeignKey( + "db.Workspace", on_delete=models.CASCADE, null=True, related_name="assets" + ) class Meta: verbose_name = "File Asset" verbose_name_plural = "File Assets" - db_table = "file_asset" + db_table = "file_assets" ordering = ("-created_at",) def __str__(self): - return self.asset - + return str(self.asset) diff --git a/apiserver/plane/db/models/cycle.py b/apiserver/plane/db/models/cycle.py index 8d7858445ee..c06ea40f23c 100644 --- a/apiserver/plane/db/models/cycle.py +++ b/apiserver/plane/db/models/cycle.py @@ -31,7 +31,7 @@ class Cycle(ProjectBaseModel): class Meta: verbose_name = "Cycle" verbose_name_plural = "Cycles" - db_table = "cycle" + db_table = "cycles" ordering = ("-created_at",) def __str__(self): @@ -54,7 +54,7 @@ class CycleIssue(ProjectBaseModel): class Meta: verbose_name = "Cycle Issue" verbose_name_plural = "Cycle Issues" - db_table = "cycle_issue" + db_table = "cycle_issues" ordering = ("-created_at",) def __str__(self): diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 908fc00e6d1..ed72e9b1922 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -7,6 +7,7 @@ # Module imports from . import ProjectBaseModel +from plane.utils.html_processor import strip_tags # TODO: Handle identifiers for Bulk Inserts - nk class Issue(ProjectBaseModel): @@ -31,7 +32,9 @@ class Issue(ProjectBaseModel): related_name="state_issue", ) name = models.CharField(max_length=255, verbose_name="Issue Name") - description = models.JSONField(verbose_name="Issue Description", blank=True) + description = models.JSONField(blank=True) + description_html = models.TextField(blank=True) + description_stripped = models.TextField(blank=True) priority = models.CharField( max_length=30, choices=PRIORITY_CHOICES, @@ -57,7 +60,7 @@ class Issue(ProjectBaseModel): class Meta: verbose_name = "Issue" verbose_name_plural = "Issues" - db_table = "issue" + db_table = "issues" ordering = ("-created_at",) def save(self, *args, **kwargs): @@ -81,6 +84,11 @@ def save(self, *args, **kwargs): ) except ImportError: pass + + # Strip the html tags using html parser + self.description_stripped = ( + strip_tags(self.description_html) if self.description_html != "" else "" + ) super(Issue, self).save(*args, **kwargs) def __str__(self): @@ -99,7 +107,7 @@ class IssueBlocker(ProjectBaseModel): class Meta: verbose_name = "Issue Blocker" verbose_name_plural = "Issue Blockers" - db_table = "issue_blocker" + db_table = "issue_blockers" ordering = ("-created_at",) def __str__(self): @@ -120,7 +128,7 @@ class Meta: unique_together = ["issue", "assignee"] verbose_name = "Issue Assignee" verbose_name_plural = "Issue Assignees" - db_table = "issue_assignee" + db_table = "issue_assignees" ordering = ("-created_at",) def __str__(self): @@ -156,11 +164,13 @@ class IssueActivity(ProjectBaseModel): null=True, related_name="issue_activities", ) + old_identifier = models.UUIDField(null=True) + new_identifier = models.UUIDField(null=True) class Meta: verbose_name = "Issue Activity" verbose_name_plural = "Issue Activities" - db_table = "issue_activity" + db_table = "issue_activities" ordering = ("-created_at",) def __str__(self): @@ -178,7 +188,7 @@ class TimelineIssue(ProjectBaseModel): class Meta: verbose_name = "Timeline Issue" verbose_name_plural = "Timeline Issues" - db_table = "issue_timeline" + db_table = "issue_timelines" ordering = ("-created_at",) def __str__(self): @@ -187,7 +197,9 @@ def __str__(self): class IssueComment(ProjectBaseModel): - comment = models.TextField(verbose_name="Comment", blank=True) + comment_stripped = models.TextField(verbose_name="Comment", blank=True) + comment_json = models.JSONField(blank=True, null=True) + comment_html = models.TextField(blank=True) attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) issue = models.ForeignKey(Issue, on_delete=models.CASCADE) # System can also create comment @@ -198,10 +210,15 @@ class IssueComment(ProjectBaseModel): null=True, ) + def save(self, *args, **kwargs): + self.comment_stripped = strip_tags(self.comment_html) if self.comment_html != "" else "" + return super(IssueComment, self).save(*args, **kwargs) + + class Meta: verbose_name = "Issue Comment" verbose_name_plural = "Issue Comments" - db_table = "issue_comment" + db_table = "issue_comments" ordering = ("-created_at",) def __str__(self): @@ -220,7 +237,7 @@ class IssueProperty(ProjectBaseModel): class Meta: verbose_name = "Issue Property" verbose_name_plural = "Issue Properties" - db_table = "issue_property" + db_table = "issue_properties" ordering = ("-created_at",) unique_together = ["user", "project"] @@ -245,7 +262,7 @@ class Label(ProjectBaseModel): class Meta: verbose_name = "Label" verbose_name_plural = "Labels" - db_table = "label" + db_table = "labels" ordering = ("-created_at",) def __str__(self): @@ -264,7 +281,7 @@ class IssueLabel(ProjectBaseModel): class Meta: verbose_name = "Issue Label" verbose_name_plural = "Issue Labels" - db_table = "issue_label" + db_table = "issue_labels" ordering = ("-created_at",) def __str__(self): @@ -282,7 +299,7 @@ class IssueSequence(ProjectBaseModel): class Meta: verbose_name = "Issue Sequence" verbose_name_plural = "Issue Sequences" - db_table = "issue_sequence" + db_table = "issue_sequences" ordering = ("-created_at",) diff --git a/apiserver/plane/db/models/module.py b/apiserver/plane/db/models/module.py index 8058942d58d..c5dfef5881c 100644 --- a/apiserver/plane/db/models/module.py +++ b/apiserver/plane/db/models/module.py @@ -41,11 +41,12 @@ class Module(ProjectBaseModel): through_fields=("module", "member"), ) + class Meta: unique_together = ["name", "project"] verbose_name = "Module" verbose_name_plural = "Modules" - db_table = "module" + db_table = "modules" ordering = ("-created_at",) def __str__(self): @@ -61,7 +62,7 @@ class Meta: unique_together = ["module", "member"] verbose_name = "Module Member" verbose_name_plural = "Module Members" - db_table = "module_member" + db_table = "module_members" ordering = ("-created_at",) def __str__(self): @@ -73,12 +74,11 @@ class ModuleIssue(ProjectBaseModel): module = models.ForeignKey( "db.Module", on_delete=models.CASCADE, related_name="issue_module" ) - issue = models.ForeignKey( + issue = models.OneToOneField( "db.Issue", on_delete=models.CASCADE, related_name="issue_module" ) class Meta: - unique_together = ["module", "issue"] verbose_name = "Module Issue" verbose_name_plural = "Module Issues" db_table = "module_issues" @@ -86,3 +86,19 @@ class Meta: def __str__(self): return f"{self.module.name} {self.issue.name}" + + +class ModuleLink(ProjectBaseModel): + + title = models.CharField(max_length=255, null=True) + url = models.URLField() + module = models.ForeignKey(Module, on_delete=models.CASCADE, related_name="link_module") + + class Meta: + verbose_name = "Module Link" + verbose_name_plural = "Module Links" + db_table = "module_links" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.module.name} {self.url}" \ No newline at end of file diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index a84d368543a..545bcd8a685 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -72,7 +72,7 @@ class Meta: unique_together = [["identifier", "workspace"], ["name", "workspace"]] verbose_name = "Project" verbose_name_plural = "Projects" - db_table = "project" + db_table = "projects" ordering = ("-created_at",) def save(self, *args, **kwargs): @@ -109,7 +109,7 @@ class ProjectMemberInvite(ProjectBaseModel): class Meta: verbose_name = "Project Member Invite" verbose_name_plural = "Project Member Invites" - db_table = "project_member_invite" + db_table = "project_member_invites" ordering = ("-created_at",) def __str__(self): @@ -134,7 +134,7 @@ class Meta: unique_together = ["project", "member"] verbose_name = "Project Member" verbose_name_plural = "Project Members" - db_table = "project_member" + db_table = "project_members" ordering = ("-created_at",) def __str__(self): @@ -156,5 +156,5 @@ class Meta: unique_together = ["name", "workspace"] verbose_name = "Project Identifier" verbose_name_plural = "Project Identifiers" - db_table = "project_identifier" + db_table = "project_identifiers" ordering = ("-created_at",) diff --git a/apiserver/plane/db/models/shortcut.py b/apiserver/plane/db/models/shortcut.py index 833fb4a5c4d..bdc09c1f2ed 100644 --- a/apiserver/plane/db/models/shortcut.py +++ b/apiserver/plane/db/models/shortcut.py @@ -18,7 +18,7 @@ class Shortcut(ProjectBaseModel): class Meta: verbose_name = "Shortcut" verbose_name_plural = "Shortcuts" - db_table = "shortcut" + db_table = "shortcuts" ordering = ("-created_at",) def __str__(self): diff --git a/apiserver/plane/db/models/social_connection.py b/apiserver/plane/db/models/social_connection.py index 20f3385a09e..938a73a627a 100644 --- a/apiserver/plane/db/models/social_connection.py +++ b/apiserver/plane/db/models/social_connection.py @@ -26,7 +26,7 @@ class SocialLoginConnection(BaseModel): class Meta: verbose_name = "Social Login Connection" verbose_name_plural = "Social Login Connections" - db_table = "social_login_connection" + db_table = "social_login_connections" ordering = ("-created_at",) def __str__(self): diff --git a/apiserver/plane/db/models/state.py b/apiserver/plane/db/models/state.py index 7a62badd80a..2c62879181b 100644 --- a/apiserver/plane/db/models/state.py +++ b/apiserver/plane/db/models/state.py @@ -32,7 +32,7 @@ class Meta: unique_together = ["name", "project"] verbose_name = "State" verbose_name_plural = "States" - db_table = "state" + db_table = "states" ordering = ("sequence",) def save(self, *args, **kwargs): diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index 1b08c8d6984..1621b19ea0e 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -67,6 +67,7 @@ class User(AbstractBaseUser, PermissionsMixin): token_updated_at = models.DateTimeField(null=True) last_workspace_id = models.UUIDField(null=True) my_issues_prop = models.JSONField(null=True) + role = models.CharField(max_length=300, null=True, blank=True) USERNAME_FIELD = "email" @@ -77,7 +78,7 @@ class User(AbstractBaseUser, PermissionsMixin): class Meta: verbose_name = "User" verbose_name_plural = "Users" - db_table = "user" + db_table = "users" ordering = ("-created_at",) def __str__(self): @@ -105,7 +106,7 @@ def send_welcome_email(sender, instance, created, **kwargs): to_email = instance.email from_email_string = f"Team Plane " - subject = f"Welcome {first_name}!" + subject = f"Welcome to Plane ✈️!" context = {"first_name": first_name, "email": instance.email} diff --git a/apiserver/plane/db/models/view.py b/apiserver/plane/db/models/view.py index db7234cc2c5..c3ea9a86617 100644 --- a/apiserver/plane/db/models/view.py +++ b/apiserver/plane/db/models/view.py @@ -14,7 +14,7 @@ class View(ProjectBaseModel): class Meta: verbose_name = "View" verbose_name_plural = "Views" - db_table = "view" + db_table = "views" ordering = ("-created_at",) def __str__(self): diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index 3474a873536..5715bb304bc 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -1,6 +1,5 @@ # Django imports from django.db import models -from django.template.defaultfilters import slugify from django.conf import settings # Module imports @@ -31,15 +30,11 @@ def __str__(self): return self.name class Meta: - unique_together = ["name", "owner"] verbose_name = "Workspace" verbose_name_plural = "Workspaces" - db_table = "workspace" + db_table = "workspaces" ordering = ("-created_at",) - def save(self, *args, **kwargs): - self.slug = slugify(self.name) - return super().save(*args, **kwargs) class WorkspaceMember(BaseModel): @@ -53,12 +48,13 @@ class WorkspaceMember(BaseModel): ) role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10) company_role = models.TextField(null=True, blank=True) + view_props = models.JSONField(null=True, blank=True) class Meta: unique_together = ["workspace", "member"] verbose_name = "Workspace Member" verbose_name_plural = "Workspace Members" - db_table = "workspace_member" + db_table = "workspace_members" ordering = ("-created_at",) def __str__(self): @@ -78,9 +74,10 @@ class WorkspaceMemberInvite(BaseModel): role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10) class Meta: + unique_together = ["email", "workspace"] verbose_name = "Workspace Member Invite" verbose_name_plural = "Workspace Member Invites" - db_table = "workspace_member_invite" + db_table = "workspace_member_invites" ordering = ("-created_at",) def __str__(self): @@ -109,7 +106,7 @@ class Meta: unique_together = ["name", "workspace"] verbose_name = "Team" verbose_name_plural = "Teams" - db_table = "team" + db_table = "teams" ordering = ("-created_at",) @@ -130,5 +127,5 @@ class Meta: unique_together = ["team", "member"] verbose_name = "Team Member" verbose_name_plural = "Team Members" - db_table = "team_member" + db_table = "team_members" ordering = ("-created_at",) diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 00a1f5e3fae..d86598a84c3 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -6,7 +6,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -SECRET_KEY = os.environ.get('SECRET_KEY') +SECRET_KEY = os.environ.get("SECRET_KEY") # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -36,6 +36,7 @@ "taggit", "fieldsignals", "django_rq", + "channels", ] MIDDLEWARE = [ @@ -109,6 +110,7 @@ } WSGI_APPLICATION = "plane.wsgi.application" +ASGI_APPLICATION = "plane.asgi.application" # Django Sites diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py index e1434c21956..68f89799776 100644 --- a/apiserver/plane/settings/local.py +++ b/apiserver/plane/settings/local.py @@ -64,4 +64,13 @@ }, } -WEB_URL = "http://localhost:3000" \ No newline at end of file +WEB_URL = "http://localhost:3000" + +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [(REDIS_HOST, REDIS_PORT)], + }, + }, +} diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index c6639181516..6fa87678d15 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -1,22 +1,27 @@ """Production settings and globals.""" -from plane.settings.local import WEB_URL -from .common import * # noqa +import ssl +from typing import Optional +from urllib.parse import urlparse import dj_database_url from urllib.parse import urlparse +from redis.asyncio.connection import Connection, RedisSSLContext + import sentry_sdk from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.redis import RedisIntegration +from .common import * # noqa + # Database -DEBUG = True +DEBUG = False DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql_psycopg2", "NAME": "plane", - "USER": os.environ.get('PGUSER'), - "PASSWORD": os.environ.get('PGPASSWORD'), - "HOST": os.environ.get('PGHOST'), + "USER": os.environ.get("PGUSER"), + "PASSWORD": os.environ.get("PGPASSWORD"), + "HOST": os.environ.get("PGHOST"), } } @@ -180,4 +185,65 @@ } } -WEB_URL = os.environ.get("WEB_URL") \ No newline at end of file + +class CustomSSLConnection(Connection): + def __init__( + self, + ssl_context: Optional[str] = None, + **kwargs, + ): + super().__init__(**kwargs) + self.ssl_context = RedisSSLContext(ssl_context) + + +class RedisSSLContext: + __slots__ = ("context",) + + def __init__( + self, + ssl_context, + ): + self.context = ssl_context + + def get(self): + return self.context + + +url = urlparse(os.environ.get("REDIS_URL")) + +DOCKERIZED = os.environ.get("DOCKERIZED", False) # Set the variable true if running in docker-compose environment + +if not DOCKERIZED: + + ssl_context = ssl.SSLContext() + ssl_context.check_hostname = False + + CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [ + { + "host": url.hostname, + "port": url.port, + "username": url.username, + "password": url.password, + "connection_class": CustomSSLConnection, + "ssl_context": ssl_context, + } + ], + }, + }, + } + +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [(os.environ.get("REDIS_URL"))], + }, + }, +} + + +WEB_URL = os.environ.get("WEB_URL") diff --git a/apiserver/plane/settings/redis.py b/apiserver/plane/settings/redis.py index e8eeaee9c40..b32cf8c8056 100644 --- a/apiserver/plane/settings/redis.py +++ b/apiserver/plane/settings/redis.py @@ -3,18 +3,21 @@ from urllib.parse import urlparse def redis_instance(): - if settings.REDIS_URL: - url = urlparse(settings.REDIS_URL) - ri = redis.Redis( - host=url.hostname, - port=url.port, - password=url.password, - ssl=True, - ssl_cert_reqs=None, - ) + # Run in local redis url is false + if not settings.REDIS_URL: + ri = redis.StrictRedis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=0) else: - ri = redis.StrictRedis( - host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=0 - ) - + # Run in prod redis url is true check with dockerized value + if settings.DOCKERIZED: + ri = redis.from_url(settings.REDIS_URL, db=0) + else: + url = urlparse(settings.REDIS_URL) + ri = redis.Redis( + host=url.hostname, + port=url.port, + password=url.password, + ssl=True, + ssl_cert_reqs=None, + ) + return ri \ No newline at end of file diff --git a/apiserver/plane/settings/staging.py b/apiserver/plane/settings/staging.py index 863589463c5..7ce09a4fd8a 100644 --- a/apiserver/plane/settings/staging.py +++ b/apiserver/plane/settings/staging.py @@ -1,13 +1,18 @@ """Production settings and globals.""" -from plane.settings.local import WEB_URL -from .common import * # noqa +import ssl +from typing import Optional +from urllib.parse import urlparse import dj_database_url from urllib.parse import urlparse +from redis.asyncio.connection import Connection, RedisSSLContext + import sentry_sdk from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.redis import RedisIntegration +from .common import * # noqa + # Database DEBUG = False DATABASES = { @@ -180,4 +185,52 @@ } } -WEB_URL = os.environ.get("WEB_URL") \ No newline at end of file +class CustomSSLConnection(Connection): + def __init__( + self, + ssl_context: Optional[str] = None, + **kwargs, + ): + super().__init__(**kwargs) + self.ssl_context = RedisSSLContext(ssl_context) + +class RedisSSLContext: + __slots__ = ( + "context", + ) + + def __init__( + self, + ssl_context, + ): + self.context = ssl_context + + def get(self): + return self.context + + +url = urlparse(os.environ.get("REDIS_URL")) + +ssl_context = ssl.SSLContext() +ssl_context.check_hostname = False + +CHANNEL_LAYERS = { + 'default': { + 'BACKEND': 'channels_redis.core.RedisChannelLayer', + 'CONFIG': { + 'hosts': [ + { + 'host': url.hostname, + 'port': url.port, + 'username': url.username, + 'password': url.password, + 'connection_class': CustomSSLConnection, + 'ssl_context': ssl_context, + } + ], + } + }, +} + + +WEB_URL = os.environ.get("WEB_URL") diff --git a/apiserver/plane/utils/html_processor.py b/apiserver/plane/utils/html_processor.py new file mode 100644 index 00000000000..5f61607e9fb --- /dev/null +++ b/apiserver/plane/utils/html_processor.py @@ -0,0 +1,24 @@ +from io import StringIO +from html.parser import HTMLParser + +class MLStripper(HTMLParser): + """ + Markup Language Stripper + """ + def __init__(self): + super().__init__() + self.reset() + self.strict = False + self.convert_charrefs= True + self.text = StringIO() + + def handle_data(self, d): + self.text.write(d) + + def get_data(self): + return self.text.getvalue() + +def strip_tags(html): + s = MLStripper() + s.feed(html) + return s.get_data() diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index e845e45617c..407c8b8c8c0 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -1,11 +1,11 @@ # base requirements -Django==3.2.14 +Django==3.2.16 django-braces==1.15.0 django-taggit==2.1.0 psycopg2==2.9.3 django-oauth-toolkit==2.0.0 -mistune==2.0.2 +mistune==2.0.3 djangorestframework==3.13.1 redis==4.2.2 django-nested-admin==3.4.0 @@ -25,4 +25,7 @@ dj_rest_auth==2.2.5 google-auth==2.9.1 google-api-python-client==2.55.0 django-rq==2.5.1 -django-redis==5.2.0 \ No newline at end of file +django-redis==5.2.0 +channels==4.0.0 +channels-redis==4.0.0 +uvicorn==0.20.0 \ No newline at end of file diff --git a/apiserver/runtime.txt b/apiserver/runtime.txt index e0252434044..cd6f13073e4 100644 --- a/apiserver/runtime.txt +++ b/apiserver/runtime.txt @@ -1 +1 @@ -python-3.9.16 \ No newline at end of file +python-3.11.1 \ No newline at end of file diff --git a/apiserver/templates/emails/auth/magic_signin.html b/apiserver/templates/emails/auth/magic_signin.html index d73b840ca0c..bc7ed12aa6b 100644 --- a/apiserver/templates/emails/auth/magic_signin.html +++ b/apiserver/templates/emails/auth/magic_signin.html @@ -1,11 +1,367 @@ - - -

- Login,

- Welcome! Login with the link below
- {{magic_url}}
or enter the code.
- {{code}} -

-

- + + + + + + + + Login for Plane + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apiserver/templates/emails/auth/user_welcome_email.html b/apiserver/templates/emails/auth/user_welcome_email.html index 3694cd90066..84d64fd8d64 100644 --- a/apiserver/templates/emails/auth/user_welcome_email.html +++ b/apiserver/templates/emails/auth/user_welcome_email.html @@ -1,17 +1,19 @@ + + - + Welcome to Plane ✈️! - + - + - - + +