diff --git a/src-ui/src/app/app.module.ts b/src-ui/src/app/app.module.ts index edbd261f6..dd34724a6 100644 --- a/src-ui/src/app/app.module.ts +++ b/src-ui/src/app/app.module.ts @@ -67,6 +67,7 @@ import { ApiVersionInterceptor } from './interceptors/api-version.interceptor' import { ColorSliderModule } from 'ngx-color/slider' import { ColorComponent } from './components/common/input/color/color.component' import { DocumentAsnComponent } from './components/document-asn/document-asn.component' +import { DocumentCommentComponent } from './components/document-comment/document-comment.component'; import { DirtyDocGuard } from './guards/dirty-doc.guard' import localeBe from '@angular/common/locales/be' @@ -173,6 +174,7 @@ function initializeApp(settings: SettingsService) { DateComponent, ColorComponent, DocumentAsnComponent, + DocumentCommentComponent, TasksComponent, ], imports: [ diff --git a/src-ui/src/app/components/document-comment/document-comment.component.html b/src-ui/src/app/components/document-comment/document-comment.component.html new file mode 100644 index 000000000..6a36f999f --- /dev/null +++ b/src-ui/src/app/components/document-comment/document-comment.component.html @@ -0,0 +1,25 @@ +
+
+
+ +
+   +
+
+
+
+ {{comment?.user?.firstname}} {{comment?.user?.lastname}} ({{comment?.user?.username}}) - {{ comment?.created | customDate}} + + + + + + + + +
+
+ {{comment.comment}} +
+
+
\ No newline at end of file diff --git a/src-ui/src/app/components/document-comment/document-comment.component.scss b/src-ui/src/app/components/document-comment/document-comment.component.scss new file mode 100644 index 000000000..778556485 --- /dev/null +++ b/src-ui/src/app/components/document-comment/document-comment.component.scss @@ -0,0 +1,22 @@ +.comment-card-body { + padding-top: .8rem !important; + padding-bottom: .8rem !important; + max-height: 10rem; + overflow: scroll; + white-space: pre-wrap; +} + +.comment-card-header a { + border: none; + background: none; + padding: 5px; + border-radius: 50%; +} + +.comment-card-header a:hover { + background: #FFF; +} + +.comment-card-header a:hover svg { + fill: var(--primary); +} \ No newline at end of file diff --git a/src-ui/src/app/components/document-comment/document-comment.component.spec.ts b/src-ui/src/app/components/document-comment/document-comment.component.spec.ts new file mode 100644 index 000000000..1f6389b1d --- /dev/null +++ b/src-ui/src/app/components/document-comment/document-comment.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DocumentCommentComponent } from './document-comment.component'; + +describe('DocumentCommentComponent', () => { + let component: DocumentCommentComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ DocumentCommentComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DocumentCommentComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/src-ui/src/app/components/document-comment/document-comment.component.ts b/src-ui/src/app/components/document-comment/document-comment.component.ts new file mode 100644 index 000000000..57b052d84 --- /dev/null +++ b/src-ui/src/app/components/document-comment/document-comment.component.ts @@ -0,0 +1,63 @@ +import { Component, OnInit } from '@angular/core'; +import { DocumentDetailComponent } from 'src/app/components/document-detail/document-detail.component'; +import { DocumentCommentService } from 'src/app/services/rest/document-comment.service'; +import { PaperlessDocumentComment } from 'src/app/data/paperless-document-comment'; + +import { take } from 'rxjs/operators'; +import { FormControl, FormGroup } from '@angular/forms'; + +@Component({ + selector: 'app-document-comment', + templateUrl: './document-comment.component.html', + styleUrls: ['./document-comment.component.scss'] +}) +export class DocumentCommentComponent implements OnInit { + + comments:PaperlessDocumentComment[]; + networkActive = false; + documentId: number; + commentForm: FormGroup = new FormGroup({ + newcomment: new FormControl('') + }) + + constructor( + private documentDetailComponent: DocumentDetailComponent, + private documentCommentService: DocumentCommentService, + ) { } + + byId(index, item: PaperlessDocumentComment) { + return item.id; + } + + async ngOnInit(): Promise { + try { + this.documentId = this.documentDetailComponent.documentId; + this.comments= await this.documentCommentService.getComments(this.documentId).pipe(take(1)).toPromise(); + } catch(err){ + this.comments = []; + } + } + + addComment(){ + this.networkActive = true + this.documentCommentService.addComment(this.documentId, this.commentForm.get("newcomment").value).subscribe(result => { + this.comments = result; + this.commentForm.get("newcomment").reset(); + this.networkActive = false; + }, error => { + this.networkActive = false; + }); + } + + deleteComment(event){ + let parent = event.target.parentElement.closest('div[comment-id]'); + if(parent){ + this.documentCommentService.deleteComment(this.documentId, parseInt(parent.getAttribute("comment-id"))).subscribe(result => { + this.comments = result; + this.networkActive = false; + }, error => { + this.networkActive = false; + }); + } + } +} \ No newline at end of file diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html index 764a587e0..ebf286895 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.html +++ b/src-ui/src/app/components/document-detail/document-detail.component.html @@ -169,6 +169,13 @@ +
  • + Comments + + + + +
  • diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index 203a56f04..d0f4ecded 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -35,6 +35,7 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' import { StoragePathEditDialogComponent } from '../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component' import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' +import { EnvironmentService } from 'src/app/services/rest/environment.service' @Component({ selector: 'app-document-detail', @@ -83,6 +84,8 @@ export class DocumentDetailComponent previewCurrentPage: number = 1 previewNumPages: number = 1 + isCommentsEnabled:boolean = false + store: BehaviorSubject isDirty$: Observable unsubscribeNotifier: Subject = new Subject() @@ -118,7 +121,8 @@ export class DocumentDetailComponent private documentTitlePipe: DocumentTitlePipe, private toastService: ToastService, private settings: SettingsService, - private storagePathService: StoragePathService + private storagePathService: StoragePathService, + private environment: EnvironmentService ) {} titleKeyUp(event) { @@ -274,6 +278,13 @@ export class DocumentDetailComponent this.suggestions = null }, }) + + this.environment.get("PAPERLESS_COMMENTS_ENABLED").subscribe(result => { + this.isCommentsEnabled = (result.value.toString().toLowerCase() === "true"?true:false); + }, error => { + this.isCommentsEnabled = false; + }) + this.title = this.documentTitlePipe.transform(doc.title) this.documentForm.patchValue(doc) } diff --git a/src-ui/src/app/data/paperless-document-comment.ts b/src-ui/src/app/data/paperless-document-comment.ts new file mode 100644 index 000000000..1b60e6c5c --- /dev/null +++ b/src-ui/src/app/data/paperless-document-comment.ts @@ -0,0 +1,8 @@ +import { ObjectWithId } from './object-with-id' +import { CommentUser } from './user-type' + +export interface PaperlessDocumentComment extends ObjectWithId { + created?: Date + comment?: string + user?: CommentUser +} \ No newline at end of file diff --git a/src-ui/src/app/data/paperless-environment.ts b/src-ui/src/app/data/paperless-environment.ts new file mode 100644 index 000000000..27dda6427 --- /dev/null +++ b/src-ui/src/app/data/paperless-environment.ts @@ -0,0 +1,3 @@ +export interface PaperlessEnvironment { + value?: string; +} \ No newline at end of file diff --git a/src-ui/src/app/data/user-type.ts b/src-ui/src/app/data/user-type.ts new file mode 100644 index 000000000..9324cab43 --- /dev/null +++ b/src-ui/src/app/data/user-type.ts @@ -0,0 +1,7 @@ +import { ObjectWithId } from './object-with-id' + +export interface CommentUser extends ObjectWithId { + username: string + firstname: string + lastname: string +} \ No newline at end of file diff --git a/src-ui/src/app/services/rest/document-comment.service.spec.ts b/src-ui/src/app/services/rest/document-comment.service.spec.ts new file mode 100644 index 000000000..112144b90 --- /dev/null +++ b/src-ui/src/app/services/rest/document-comment.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { DocumentCommentService } from './document-comment.service'; + +describe('DocumentCommentService', () => { + let service: DocumentCommentService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(DocumentCommentService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/src-ui/src/app/services/rest/document-comment.service.ts b/src-ui/src/app/services/rest/document-comment.service.ts new file mode 100644 index 000000000..b5739d65e --- /dev/null +++ b/src-ui/src/app/services/rest/document-comment.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { PaperlessDocumentComment } from 'src/app/data/paperless-document-comment'; +import { AbstractPaperlessService } from './abstract-paperless-service'; +import { Observable } from 'rxjs'; +import { PaperlessDocumentCommentFrame } from 'src/app/data/paperless-document-comment-frame'; + +@Injectable({ + providedIn: 'root' +}) +export class DocumentCommentService extends AbstractPaperlessService { + + constructor(http: HttpClient) { + super(http, 'documents') + } + + + getComments(id: number): Observable { + return this.http.get(this.getResourceUrl(id, "comments")) + } + + addComment(id: number, comment): Observable{ + return this.http.post(this.getResourceUrl(id, 'comments'), {"payload": comment}) + } + + deleteComment(documentId: number, commentId: number): Observable{ + let httpParams = new HttpParams(); + httpParams = httpParams.set("commentId", commentId.toString()); + return this.http.delete(this.getResourceUrl(documentId, 'comments'), {params: httpParams}); + } +} \ No newline at end of file diff --git a/src-ui/src/app/services/rest/environment.service.spec.ts b/src-ui/src/app/services/rest/environment.service.spec.ts new file mode 100644 index 000000000..941a180b9 --- /dev/null +++ b/src-ui/src/app/services/rest/environment.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { EnvironmentService } from './environment.service'; + +describe('EnvironmentService', () => { + let service: EnvironmentService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(EnvironmentService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/src-ui/src/app/services/rest/environment.service.ts b/src-ui/src/app/services/rest/environment.service.ts new file mode 100644 index 000000000..86ac146ea --- /dev/null +++ b/src-ui/src/app/services/rest/environment.service.ts @@ -0,0 +1,22 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { PaperlessEnvironment } from 'src/app/data/paperless-environment'; +import { environment } from 'src/environments/environment' + +@Injectable({ + providedIn: 'root' +}) +export class EnvironmentService { + + protected baseUrl: string = environment.apiBaseUrl + + constructor(protected http: HttpClient) { } + + get(environment: string): Observable { + let httpParams = new HttpParams(); + httpParams = httpParams.set('name', environment); + + return this.http.get(`${this.baseUrl}environment/`, {params: httpParams}) + } +} \ No newline at end of file diff --git a/src/documents/management/commands/document_exporter.py b/src/documents/management/commands/document_exporter.py index 526d59368..da00d10f5 100644 --- a/src/documents/management/commands/document_exporter.py +++ b/src/documents/management/commands/document_exporter.py @@ -12,6 +12,7 @@ from django.core import serializers from django.core.management.base import BaseCommand from django.core.management.base import CommandError from django.db import transaction +from documents.models import Comment from documents.models import Correspondent from documents.models import Document from documents.models import DocumentType @@ -126,6 +127,9 @@ class Command(BaseCommand): serializers.serialize("json", DocumentType.objects.all()), ) + manifest += json.loads( + serializers.serialize("json", Comment.objects.all())), + documents = Document.objects.order_by("id") document_map = {d.pk: d for d in documents} document_manifest = json.loads(serializers.serialize("json", documents)) diff --git a/src/documents/migrations/1023_add_comments.py b/src/documents/migrations/1023_add_comments.py new file mode 100644 index 000000000..8ae779e66 --- /dev/null +++ b/src/documents/migrations/1023_add_comments.py @@ -0,0 +1,19 @@ +from django.db import migrations, models + +class Migration(migrations.Migration): + dependencies = [ + ('documents', '1016_auto_20210317_1351'), + ] + + operations = [ + migrations.CreateModel( + name='Comment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('comment', models.TextField()), + ('created', models.DateTimeField(auto_now_add=True)), + ('document_id', models.PositiveIntegerField()), + ('user_id', models.PositiveIntegerField()) + ], + ) + ] \ No newline at end of file diff --git a/src/documents/models.py b/src/documents/models.py index f6df273ad..cb64946c3 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -537,3 +537,40 @@ class PaperlessTask(models.Model): blank=True, ) acknowledged = models.BooleanField(default=False) + +class Comment(models.Model): + comment = models.TextField( + _("content"), + blank=True, + help_text=_("Comment for the document") + ) + + created = models.DateTimeField( + _("created"), + default=timezone.now, db_index=True) + + document = models.ForeignKey( + Document, + blank=True, + null=True, + related_name="documents", + on_delete=models.CASCADE, + verbose_name=_("document") + ) + + user = models.ForeignKey( + User, + blank=True, + null=True, + related_name="users", + on_delete=models.SET_NULL, + verbose_name=_("user") + ) + + class Meta: + ordering = ("created",) + verbose_name = _("comment") + verbose_name_plural = _("comments") + + def __str__(self): + return self.content \ No newline at end of file diff --git a/src/documents/views.py b/src/documents/views.py index b261f37fd..4633642e6 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -21,6 +21,8 @@ from django.db.models.functions import Lower from django.http import Http404 from django.http import HttpResponse from django.http import HttpResponseBadRequest +from django.http import HttpResponseNotAllowed +from django.http import HttpResponseNotFound from django.utils.decorators import method_decorator from django.utils.translation import get_language from django.views.decorators.cache import cache_control @@ -62,6 +64,7 @@ from .matching import match_correspondents from .matching import match_document_types from .matching import match_storage_paths from .matching import match_tags +from .models import Comment from .models import Correspondent from .models import Document from .models import DocumentType @@ -379,6 +382,61 @@ class DocumentViewSet( except (FileNotFoundError, Document.DoesNotExist): raise Http404() + def getComments(self, doc): + return [ + { + "id":c.id, + "comment":c.comment, + "created":c.created, + "user":{ + "id":c.user.id, + "username": c.user.username, + "firstname":c.user.first_name, + "lastname":c.user.last_name + } + } for c in Comment.objects.filter(document=doc).order_by('-created') + ]; + + @action(methods=['get', 'post', 'delete'], detail=True) + def comments(self, request, pk=None): + if settings.PAPERLESS_COMMENTS_ENABLED != True: + return HttpResponseNotAllowed("comment function is disabled") + + try: + doc = Document.objects.get(pk=pk) + except Document.DoesNotExist: + raise Http404() + + currentUser = request.user; + + if request.method == 'GET': + try: + return Response(self.getComments(doc)); + except Exception as e: + return Response({"error": str(e)}); + elif request.method == 'POST': + try: + c = Comment.objects.create( + document = doc, + comment=request.data["payload"], + user=currentUser + ); + c.save(); + + return Response(self.getComments(doc)); + except Exception as e: + return Response({ + "error": str(e) + }); + elif request.method == 'DELETE': + comment = Comment.objects.get(id=int(request.GET.get("commentId"))); + comment.delete(); + return Response(self.getComments(doc)); + + return Response({ + "error": "error" + }); + class SearchResultSerializer(DocumentSerializer): def to_representation(self, instance): @@ -835,3 +893,32 @@ class AcknowledgeTasksView(GenericAPIView): return Response({"result": result}) except Exception: return HttpResponseBadRequest() + +class EnvironmentView(APIView): + + permission_classes = (IsAuthenticated,) + + def get(self, request, format=None): + if 'name' in request.query_params: + name = request.query_params['name'] + else: + return HttpResponseBadRequest("name required") + + if(name not in settings.PAPERLESS_FRONTEND_ALLOWED_ENVIRONMENTS and settings.PAPERLESS_DISABLED_FRONTEND_ENVIRONMENT_CHECK == False): + return HttpResponseNotAllowed("environment not allowed to request") + + value = None + try: + value = getattr(settings, name) + except: + try: + value = os.getenv(name) + except: + value = None + + if value == None: + return HttpResponseNotFound("environment not found") + + return Response({ + "value": str(value) + }); diff --git a/src/paperless/settings.py b/src/paperless/settings.py index 2ce99ac0e..e42cf4359 100644 --- a/src/paperless/settings.py +++ b/src/paperless/settings.py @@ -566,6 +566,14 @@ CONVERT_MEMORY_LIMIT = os.getenv("PAPERLESS_CONVERT_MEMORY_LIMIT") GS_BINARY = os.getenv("PAPERLESS_GS_BINARY", "gs") +# Comment settings +PAPERLESS_COMMENTS_ENABLED = __get_boolean("PAPERLESS_COMMENTS_ENABLED", "NO") + +# allowed environments for frontend +PAPERLESS_DISABLED_FRONTEND_ENVIRONMENT_CHECK = __get_boolean("PAPERLESS_DISABLED_FRONTEND_ENVIRONMENT_CHECK", "NO") +PAPERLESS_FRONTEND_ALLOWED_ENVIRONMENTS = [ + "PAPERLESS_COMMENTS_ENABLED" +] # Pre-2.x versions of Paperless stored your documents locally with GPG # encryption, but that is no longer the default. This behaviour is still diff --git a/src/paperless/urls.py b/src/paperless/urls.py index 46309e1e6..6ba1ee263 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -8,6 +8,7 @@ from django.utils.translation import gettext_lazy as _ from django.views.decorators.csrf import csrf_exempt from django.views.generic import RedirectView from documents.views import AcknowledgeTasksView +from documents.views import EnvironmentView from documents.views import BulkDownloadView from documents.views import BulkEditView from documents.views import CorrespondentViewSet @@ -94,6 +95,7 @@ urlpatterns = [ AcknowledgeTasksView.as_view(), name="acknowledge_tasks", ), + re_path(r"^environment/", EnvironmentView.as_view()), path("token/", views.obtain_auth_token), ] + api_router.urls,