Skip to content

Commit

Permalink
feat: My Documents + other bits (#57)
Browse files Browse the repository at this point in the history
* feat: Map User to DatatrackerPerson

* feat: set isManager flag in profile

* chore: A few real managers for demo

* feat: Only mgrs can Manage Assignments

* chore: View page as another user for dev/demo

* chore: "Documents" -> "My Documents"

* feat: Pass rpcPersonId back with profile

* feat: Show My Documents
  • Loading branch information
jennifer-richards authored Nov 2, 2023
1 parent db8b75b commit 1664f70
Show file tree
Hide file tree
Showing 9 changed files with 228 additions and 28 deletions.
49 changes: 49 additions & 0 deletions client/components/HeaderNav.vue
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,32 @@
<Icon name="solar:bell-bold-duotone" size="1.25em" aria-hidden="true" />
</button>

<!-- Select User (for demo only) -->
<HeadlessMenu v-if="allUsers.length > 0" as="div" class="relative">
<HeadlessMenuButton class="-m-2.5 mx-2 text-gray-400 dark:text-neutral-400 hover:text-gray-500 dark:hover:text-violet-400">
<span class="sr-only">Select user (for demo only)</span>
<Icon name="solar:users-group-two-rounded-line-duotone" size="1.25em" aria-hidden="true" />
</HeadlessMenuButton>
<transition enter-active-class="transition ease-out duration-100" enter-from-class="transform opacity-0 scale-95" enter-to-class="transform opacity-100 scale-100" leave-active-class="transition ease-in duration-75" leave-from-class="transform opacity-100 scale-100" leave-to-class="transform opacity-0 scale-95">
<HeadlessMenuItems class="absolute left-0 z-10 mt-2.5 min-w-max origin-top-left rounded-md bg-white py-2 shadow-lg ring-1 ring-gray-900/5 focus:outline-none">
<HeadlessMenuItem v-if="userStore.pretendingToBe" v-slot="{ active }">
<div :class="[active ? 'bg-gray-50' : '', 'block px-3 py-1 text-sm leading-6 text-gray-900']"
@click="switchUser(null)">
Yourself
</div>
</HeadlessMenuItem>
<HeadlessMenuItem v-for="item in allUsers" :key="item.id" v-slot="{ active }">
<div :class="[active ? 'bg-gray-50' : '', 'block px-3 py-1 text-sm leading-6 text-gray-900']"
@click="switchUser(item.id)">
<Icon v-if="item.id === userStore.pretendingToBe"
name="uil:arrow-right" class="h-6 w-6 -ml-1" aria-hidden="true" />
{{ item.name }}
</div>
</HeadlessMenuItem>
</HeadlessMenuItems>
</transition>
</HeadlessMenu>

<!-- Separator -->
<div class="hidden lg:block lg:h-6 lg:w-px lg:bg-gray-900/10 dark:lg:bg-white/20" aria-hidden="true" />

Expand Down Expand Up @@ -99,6 +125,7 @@
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
const api = useApi()
const csrf = useCookie('csrftoken', { sameSite: 'strict' })
async function logout () {
Expand All @@ -111,9 +138,31 @@ async function logout () {
const siteStore = useSiteStore()
const userStore = useUserStore()
// FUNCTIONS
function switchUser (rpcPersonId) {
userStore.pretendToBe(rpcPersonId)
}
// DATA
const userNavigation = [
{ name: 'Your profile', href: '/' },
]
const { data: allUsers } = await useAsyncData(
'allUsers',
async () => {
try {
return await api.rpcPersonList()
} catch {
// nop
}
},
{
default: () => ([]),
server: false,
transform: (resp) => resp.toSorted((a, b) => a.name.localeCompare(b.name))
}
)
</script>
2 changes: 1 addition & 1 deletion client/components/SidebarNav.vue
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ const currentBaseLink = computed(() => route.path.indexOf('/', 1) > 0 ? `/${rout
const navigation = [
{ name: 'Dashboard', href: '/', icon: h(Icon, { name: 'solar:widget-6-bold-duotone' }) },
{ name: 'Queue', href: '/queue', icon: h(Icon, { name: 'solar:layers-minimalistic-bold-duotone' }) },
{ name: 'Documents', href: '/docs', icon: h(Icon, { name: 'solar:documents-minimalistic-line-duotone' }) },
{ name: 'My Documents', href: '/docs', icon: h(Icon, { name: 'solar:documents-minimalistic-line-duotone' }) },
{ name: 'Team', href: '/team', icon: h(Icon, { name: 'solar:users-group-rounded-bold-duotone' }) },
{ name: 'Statistics', href: '/stats', icon: h(Icon, { name: 'solar:chart-line-duotone' }) },
{ name: 'Final Reviews', href: '/auth48', icon: h(Icon, { name: 'solar:diploma-verified-broken' }) }
Expand Down
73 changes: 67 additions & 6 deletions client/pages/docs/index.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,74 @@
<template>
<TitleBlock title="Documents" summary="Beep boop">
<TitleBlock title="My Documents" summary="Beep boop">
<template #right>

<RefreshButton class="mr-3"/>
<NuxtLink
v-if="userStore.isManager"
class="max-w-l items-center rounded-md bg-violet-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-violet-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
to="/docs/assignments">
Manage Assignments
</NuxtLink>
</template>
</TitleBlock>
<div class="mt-8 flow-root">
<a href="/docs/assignments"
class="max-w-l items-center rounded-md bg-violet-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-violet-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
Manage Assignments
</a>
<DocumentTable
:columns="columns"
:data="myAssignments.map(a => a.rfcToBe)"
row-key="id"
:loading="pending"
/>
</div>
</template>

<script setup>
import { useAsyncData } from '#app'
const api = useApi()
const userStore = useUserStore()
// COMPUTED
const myAssignments = computed(() => allAssignments.value?.filter(
(a) => a.person === userStore.rpcPersonId
).map(
(a) => ({ ...a, rfcToBe: allDocuments.value?.find(d => d.id === a.rfcToBe) })
))
const pending = computed(() => assignmentsPending.value || documentsPending.value)
// DATA
const columns = [
{
key: 'name',
label: 'Document',
field: 'name',
classes: 'text-sm font-medium',
link: row => `/docs/${row.name}`
},
{
key: 'labels',
label: 'Labels',
labels: row => row.labels.map(lblId => labels.value.find(lbl => lbl.id === lblId)) || []
}
]
const { data: allAssignments, pending: assignmentsPending } = await useAsyncData(
'allAssignments',
() => api.assignmentsList(),
{ server: false, default: () => ([]) }
)
const { data: allDocuments, pending: documentsPending } = await useAsyncData(
'allDocuments',
() => api.documentsList(),
{ server: false, default: () => ([]) }
)
const { data: labels } = await useAsyncData(
'labels',
() => api.labelsList(),
{ server: false, default: () => ([]) }
)
</script>
17 changes: 15 additions & 2 deletions client/stores/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,32 @@ export const useUserStore = defineStore('user', {
authenticated: false,
name: 'Guest',
email: '',
avatar: ''
avatar: '',
rpcPersonId: null,
isManager: false,
pretendingToBe: null // demo/debug only!
}),
getters: {},
actions: {
async refreshAuth () {
const profileData = await $fetch('/api/rpc/profile')
const profileData = await $fetch(
this.pretendingToBe
? `/api/rpc/profile/${this.pretendingToBe}`
: '/api/rpc/profile/'
)
this.authenticated = profileData.authenticated
if (this.authenticated) {
this.id = profileData.id
this.name = profileData.name
this.email = profileData.email
this.avatar = profileData.avatar
this.rpcPersonId = profileData.rpcPersonId
this.isManager = profileData.isManager
}
},
async pretendToBe (rpcPersonId) {
this.pretendingToBe = rpcPersonId
return await this.refreshAuth()
}
}
})
10 changes: 10 additions & 0 deletions datatracker/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,18 @@
from django.db import models


class DatatrackerPersonQuerySet(models.QuerySet):
@with_rpcapi
def by_subject_id(self, subject_id, *, rpcapi: rpcapi_client.DefaultApi):
dtpers = rpcapi.get_subject_person_by_id(subject_id=subject_id)
if dtpers is None:
return super().none()
return super().filter(datatracker_id=dtpers.id)


class DatatrackerPerson(models.Model):
"""Person known to the datatracker"""
objects = DatatrackerPersonQuerySet.as_manager()

# datatracker uses AutoField for this, which is only an IntegerField, but might as well go big
datatracker_id = models.BigIntegerField(
Expand Down
71 changes: 53 additions & 18 deletions rpc/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,27 +20,59 @@
QueueItemSerializer,
RfcToBeSerializer,
RpcPersonSerializer,
RpcRoleSerializer
RpcRoleSerializer,
)


@api_view(["GET"])
@permission_classes([AllowAny])
@with_rpcapi
def profile(request, *, rpcapi: rpcapi_client.DefaultApi):
def profile(request):
"""Get profile of current user"""
user = request.user
if not user.is_authenticated:
return JsonResponse({"authenticated": False})
return JsonResponse({
"authenticated": True,
"id": user.pk,
"name": user.name,
"avatar": user.avatar,
})
dt_person = user.datatracker_person()
# hasattr() test also handles None case
rpcperson = dt_person.rpcperson if hasattr(dt_person, "rpcperson") else None
return JsonResponse(
{
"authenticated": True,
"id": user.pk,
"name": user.name,
"avatar": user.avatar,
"rpcPersonId": rpcperson.id if rpcperson is not None else None,
"isManager": (
False
if rpcperson is None
else rpcperson.can_hold_role.filter(slug="manager").exists()
),
}
)


@extend_schema(responses=RpcPersonSerializer)
# This is for debugging / demo purposes only!
@extend_schema(operation_id="profile_retrieve_demo_only", responses=OpenApiTypes.OBJECT)
@api_view(["GET"])
def profile_as_person(request, rpc_person_id):
rpcperson = RpcPerson.objects.filter(pk=rpc_person_id).first()
if rpcperson is None:
return Response(status=404)
return JsonResponse(
{
"authenticated": request.user.is_authenticated,
"id": None,
"name": rpcperson.datatracker_person.plain_name(),
"avatar": f"https://i.pravatar.cc/150?u={rpcperson.datatracker_person.datatracker_id}",
"rpcPersonId": rpcperson.id,
"isManager": (
False
if rpcperson is None
else rpcperson.can_hold_role.filter(slug="manager").exists()
),
}
)

@extend_schema(responses=RpcPersonSerializer(many=True))
@api_view(["GET"])
@with_rpcapi
def rpc_person(request, *, rpcapi: rpcapi_client.DefaultApi):
Expand All @@ -57,7 +89,9 @@ def rpc_person(request, *, rpcapi: rpcapi_client.DefaultApi):
)


@extend_schema(operation_id="submissions_list", responses=OpenApiTypes.OBJECT) # not very specific...
@extend_schema(
operation_id="submissions_list", responses=OpenApiTypes.OBJECT
) # not very specific...
@api_view(["GET"])
@with_rpcapi
def submissions(request, *, rpcapi: rpcapi_client.DefaultApi):
Expand Down Expand Up @@ -101,9 +135,7 @@ def submissions(request, *, rpcapi: rpcapi_client.DefaultApi):
# Filter out I-Ds that already have an RfcToBe
already_in_queue = RfcToBe.objects.filter(
draft__datatracker_id__in=[s["pk"] for s in submitted]
).values_list(
"draft__datatracker_id", flat=True
)
).values_list("draft__datatracker_id", flat=True)
submitted = [s for s in submitted if s["pk"] not in already_in_queue]
return JsonResponse({"submitted": submitted}, safe=False)

Expand Down Expand Up @@ -140,7 +172,7 @@ def import_submission(request, document_id, rpcapi: rpcapi_client.DefaultApi):
"title": draft_info.title,
"stream": draft_info.stream,
"pages": draft_info.pages,
}
},
)

# Create the RfcToBe
Expand All @@ -154,8 +186,12 @@ def import_submission(request, document_id, rpcapi: rpcapi_client.DefaultApi):
submitted_boilerplate="trust200902",
intended_boilerplate="trust200902",
submitted_format="xml-v3",
submitted_std_level=StdLevelNameFactory(slug="ps", name="Proposed Standard").pk,
intended_std_level=StdLevelNameFactory(slug="ps", name="Proposed Standard").pk,
submitted_std_level=StdLevelNameFactory(
slug="ps", name="Proposed Standard"
).pk,
intended_std_level=StdLevelNameFactory(
slug="ps", name="Proposed Standard"
).pk,
submitted_stream=StreamNameFactory(slug="ietf", name="IETF").pk,
intended_stream=StreamNameFactory(slug="ietf", name="IETF").pk,
internal_goal=initial_data["external_deadline"],
Expand Down Expand Up @@ -199,7 +235,6 @@ def clusters(request):
)



@api_view(["GET"])
def cluster(request, number):
"""Return data for a specific cluster"""
Expand Down
26 changes: 26 additions & 0 deletions rpc/management/commands/create_rpc_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,32 @@ def handle(self, *args, **options):
self.people: dict[str, RpcPerson] = {}
self.create_rpc_people()
self.create_documents()
self.create_real_people()


@with_rpcapi
def create_real_people(self, *, rpcapi: rpcapi_client.DefaultApi):
"""Create RpcPerson / DatatrackerPerson records for real people"""
self.people["jennifer"] = RpcPersonFactory(
datatracker_person__datatracker_id=rpcapi.get_subject_person_by_id(subject_id="14733").id,
can_hold_role=["manager"],
)
self.people["robert"] = RpcPersonFactory(
datatracker_person__datatracker_id=rpcapi.get_subject_person_by_id(subject_id="420").id,
can_hold_role=["manager"],
)
self.people["jean"] = RpcPersonFactory(
datatracker_person__datatracker_id=rpcapi.get_subject_person_by_id(subject_id="2706").id,
can_hold_role=["manager"],
)
self.people["sandy"] = RpcPersonFactory(
datatracker_person__datatracker_id=rpcapi.get_subject_person_by_id(subject_id="5709").id,
can_hold_role=["manager"],
)
self.people["alice"] = RpcPersonFactory(
datatracker_person__datatracker_id=rpcapi.get_subject_person_by_id(subject_id="2173").id,
can_hold_role=["manager"],
)

@with_rpcapi
def create_rpc_people(self, *, rpcapi: rpcapi_client.DefaultApi):
Expand Down
5 changes: 5 additions & 0 deletions rpcauth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from django.contrib.auth.models import AbstractUser
from django.db import models

from datatracker.models import DatatrackerPerson


class User(AbstractUser):
"""RPC tool user class"""
Expand All @@ -21,3 +23,6 @@ class User(AbstractUser):
)

avatar = models.URLField(blank=True)

def datatracker_person(self):
return DatatrackerPerson.objects.by_subject_id(self.datatracker_subject_id).first()
3 changes: 2 additions & 1 deletion rpctracker/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ def to_url(self, value):
path("login/", views.index),
path("api/rpc/clusters/", rpc_api.clusters),
path("api/rpc/clusters/<int:number>", rpc_api.cluster),
path("api/rpc/profile", rpc_api.profile),
path("api/rpc/profile/", rpc_api.profile),
path("api/rpc/profile/<int:rpc_person_id>", rpc_api.profile_as_person), # for demo only
path("api/rpc/rpc_person/", rpc_api.rpc_person),
path("api/rpc/submissions/", rpc_api.submissions),
path("api/rpc/submissions/<int:document_id>/", rpc_api.submission),
Expand Down

0 comments on commit 1664f70

Please sign in to comment.