Replace creators with profiles (#348)
* Remove model abstraction of creators * Add intermediary model between Entry and UserProfile * Migrate data from Creators to intermediary model * Modify serializers * Fix tests * Remove old creator models * Add searching functionality for profiles * Fix #349 - Add id to profile entry routes * Update README
This commit is contained in:
Родитель
bc0c05c92a
Коммит
89a46577f1
208
README.md
208
README.md
|
@ -131,6 +131,12 @@ This is a healthcheck route that can be used to check the status of the pulse AP
|
|||
|
||||
### `GET /api/pulse/creators/?name=...`
|
||||
|
||||
#### DEPRECATION NOTICE
|
||||
|
||||
This route has been deprecated in favor of [`GET /api/pulse/profiles/?name=`](). Version 1 (v1) of this API route is supported for now.
|
||||
|
||||
#### Version 1 - `GET /api/pulse/v1/creators/?name=...`
|
||||
|
||||
Gets the list of all creators whose name starts with the string passed as `name` argument. This yields a response that uses the following schema:
|
||||
|
||||
```
|
||||
|
@ -154,23 +160,92 @@ In this response:
|
|||
|
||||
- `count` property represents how many hits the system knows about,
|
||||
- `next` is a URL if there are more results than fit in a single result set (set to `null` if there are no additional pages of results).
|
||||
- `results` points to an array of creator records, where each creator has a `name` (string data), a `creator_id` (integer) as well as a `profile_id` (which is either an integer if the creator has an associated profile, or `false` if the creator does not have an associated profile). By default, this array will contain 6 objects, but this number can be increased (to a maximum of 20) by adding `&page_size=...` to the query with the desired results-per-page number.
|
||||
- `results` points to an array of creator records, where each creator has a `name` (string data), a `creator_id` (integer which is the same as `profile_id`) as well as a `profile_id` (integer). By default, this array will contain 6 objects, but this number can be increased (to a maximum of 20) by adding `&page_size=...` to the query with the desired results-per-page number.
|
||||
|
||||
## Entries
|
||||
|
||||
### Entry object schema
|
||||
|
||||
```
|
||||
{
|
||||
id: <integer: id of the entry>,
|
||||
is_bookmarked: <boolean: whether this entry is bookmarked for the currently authenticated user>,
|
||||
tags: <array: list of tags as strings>,
|
||||
issues: <array: list of issues as strings>,
|
||||
help_types: <array: list of help categories as strings>,
|
||||
published_by: <string: name of the user who published this entry>,
|
||||
submitter_profile_id: <integer: id of the profile of the user who published this entry>,
|
||||
bookmark_count: <integer: number of users who have bookmarked this entry>,
|
||||
related_creators: <array: list of related creator objects (see below)>,
|
||||
title: <string: title of this entry>,
|
||||
content_url: <string: external url for the contents of the entry>,
|
||||
description: <string: description of the entry>,
|
||||
get_involved: <string: CTA text for this entry>,
|
||||
get_involved_url: <string: CTA url for this entry>,
|
||||
interest: <string: description of why this entry might be interesting>,
|
||||
featured: <boolean: whether this entry is featured on the pulse homepage or not>,
|
||||
published_by_creator: <boolean: does this entry mention the user who submitted it as a creator>,
|
||||
thumbnail: <string: url to a thumbnail image for the entry>,
|
||||
created: <timestamp: ISO 8601 timestamp of when this entry was created>,
|
||||
moderation_state: <integer: id of the moderation state of this entry>
|
||||
}
|
||||
```
|
||||
|
||||
### Related creator object schema
|
||||
|
||||
The related creator object will differ based on the API version that is used.
|
||||
|
||||
#### Version 2 - `GET /api/pulse/v2/`
|
||||
|
||||
Each `related_creator` object will have the following schema:
|
||||
|
||||
```
|
||||
{
|
||||
name: <string: name of the creator profile>
|
||||
profile_id: <integer: id of the creator profile>
|
||||
}
|
||||
```
|
||||
|
||||
#### Version 1 - `GET /api/pulse/v1/`
|
||||
|
||||
Each `related_creator` object should have the following schema:
|
||||
|
||||
```
|
||||
{
|
||||
name: <string: name of the creator profile>
|
||||
profile_id: <integer: id of the creator profile>
|
||||
creator_id: <integer: same as the profile_id>
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/pulse/entries/` with optional `?format=json`
|
||||
|
||||
This retrieves the full list of entries as stored in the database. As a base URL call this returns an HTML page with formatted results, as url with `?format=json` suffix this results a JSON object for use as data input to applications, webpages, etc.
|
||||
This retrieves a paginated list of all [entries](#entry-object-schema) as stored in the database. As a base URL call this returns an HTML page with formatted results, as url with `?format=json` suffix this results a JSON object for use as data input to applications, webpages, etc.
|
||||
|
||||
This route takes a swathe of optional arguments for filtering the entry set, visit this route in the browser for more detailed information on all available query arguments.
|
||||
The schema for the payload returned is:
|
||||
```
|
||||
{
|
||||
"count": <integer: number of entries>,
|
||||
"next": <string: url to the next page of entries> or null,
|
||||
"previous": <string: url to the previous page of entries> or null,
|
||||
"results": <array: list of entry objects (see above)>
|
||||
}
|
||||
```
|
||||
|
||||
#### Filters
|
||||
|
||||
Please run the server and see [http://localhost:8000/entries](http://localhost:8000/entries) for all supported filters.
|
||||
- `?search=<string>` - Search for entries by their title, description, CTA, interest, creators, or tags
|
||||
- `?ids=<comma-separated integers>` - Filter entries with specific ids
|
||||
- `?tag=<string>` - Filter entries by a specific tag
|
||||
- `?issue=<string>` - Filter entries by an issue area
|
||||
- `?help_type=<string>` - Filter entries by a specific help category
|
||||
- `?featured=<true or false>` - Filter featured or non-featured entries
|
||||
- `?ordering=<string>` - Order entries by a certain property e.g. `?ordering=title`. Prepend the property with a hyphen to get entries in descending order, e.g. `?ordering=-title`
|
||||
- `?moderationstate=<string>` - Filter entries by its moderation state. This filter will only be applied if the API call was made by an authenticated user with moderation permissions
|
||||
|
||||
### `GET /api/pulse/entries/<id=number>/` with optional `?format=json`
|
||||
|
||||
This retrieves a single entry with the indicated `id` as stored in the database. As a base URL call this returns an HTML page with formatted results, as url with `?format=json` suffix this results a JSON object for use as data input to applications, webpages, etc.
|
||||
This retrieves a single [entry](#entry-object-schema) with the indicated `id` as stored in the database. As a base URL call this returns an HTML page with formatted results, as url with `?format=json` suffix this results a JSON object for use as data input to applications, webpages, etc.
|
||||
|
||||
|
||||
### `POST /api/pulse/entries/`
|
||||
|
@ -199,10 +274,45 @@ POSTing of entries requires sending the following payload object:
|
|||
tags: optional array of strings
|
||||
issue: optional string, must match value from [GET /issues?format=json]
|
||||
help_type: optional string, must match value from [GET /helptypes?format=json]
|
||||
related_creators: optional array of objects where each object either has a creator_id or a name. The creator_id should be the id of an existing creator.
|
||||
related_creators: optional array of related creator objects (see below)where each object either has a creator_id or a name. The creator_id should be the id of an existing creator.
|
||||
published_by_creator: optional boolean to indicate that this user is (one of) the content creator(s)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
__Related creator object schema__
|
||||
|
||||
The related creator object will differ based on the API version that is used.
|
||||
|
||||
#### Version 2 - `POST /api/pulse/v2/entries/`
|
||||
|
||||
Each `related_creator` object should have the following schema:
|
||||
|
||||
```
|
||||
{
|
||||
name: optional string that represents a profile that does not exist yet
|
||||
profile_id: optional id of an existing profile
|
||||
}
|
||||
```
|
||||
|
||||
Either the `name` or the `profile_id` must be specified.
|
||||
|
||||
#### Version 1 - `POST /api/pulse/v1/entries/`
|
||||
|
||||
Each `related_creator` object should have the following schema:
|
||||
|
||||
```
|
||||
{
|
||||
name: optional string that represents a creator that does not exist yet
|
||||
creator_id: optional id of an existing creator/profile
|
||||
}
|
||||
```
|
||||
|
||||
Either the `name` or the `creator_id` must be specified.
|
||||
|
||||
---
|
||||
|
||||
Also note that this POST **must** be accompanied by the following header:
|
||||
|
||||
```
|
||||
|
@ -296,7 +406,7 @@ This operation requires a payload of the following form:
|
|||
|
||||
### `GET /api/pulse/entries/bookmarks` with optional `?format=json`
|
||||
|
||||
Get the list of all entries that have been bookmarked by the currently authenticated user. Calling this as anonymous user yields an object with property `count` equals to `0`. As a base URL call this returns an HTML page with formatted result, as url with `?format=json` suffix this results a JSON object for use as data input to applications, webpages, etc.
|
||||
Get the list of all [entries](#entry-object-schema) that have been bookmarked by the currently authenticated user. Calling this as anonymous user yields an object with property `count` equals to `0`. As a base URL call this returns an HTML page with formatted result, as url with `?format=json` suffix this results a JSON object for use as data input to applications, webpages, etc.
|
||||
|
||||
## Help Types
|
||||
|
||||
|
@ -340,18 +450,42 @@ Fetches the same data as above, but restricted to an individual issue queried fo
|
|||
|
||||
## Profiles
|
||||
|
||||
### Profile object schema
|
||||
|
||||
This represents a full profile object schema. Changes to the schema based on the API version are mentioned below the schema.
|
||||
|
||||
```
|
||||
{
|
||||
profile_id: <integer: id of the profile>,
|
||||
custom_name: <string: a custom name for this profile or empty string if not set>,
|
||||
name: <string: the custom name if set, otherwise the name of the user associated with this profile>,
|
||||
location: <string: location of the person this profile is associated to>,
|
||||
thumbnail: <string: url of the thumbnail for this profile>,
|
||||
issues: <array: list of issue areas related to this profile as strings>,
|
||||
twitter: <string: url to Twitter profile or empty string if not set>,
|
||||
linkedin: <string: url to LinkedIn profile or empty string if not set>,
|
||||
github: <string: url to Github profile or empty string if not set>,
|
||||
website: <string: url to personal website or empty string if not set>,
|
||||
user_bio: <string: biography of this profile>,
|
||||
profile_type: <string: type of profile>,
|
||||
my_profile: <boolean: whether this profile belongs to the currently authenticated user or not>
|
||||
}
|
||||
```
|
||||
|
||||
#### Version 2 - `/api/pulse/v2/profiles/*`
|
||||
|
||||
Returns a user profile object as specified by the schema exactly as shown above.
|
||||
|
||||
#### Version 1 - `/api/pulse/v1/profiles/*`
|
||||
|
||||
Returns a user profile object as specified by the schema above with two additional properties in the schema:
|
||||
|
||||
- `published_entries` - <array: list of [entries](#entry-object-schema) that were published by the user associated with this profile>
|
||||
- `created_entries` - <array: list of [entries](#entry-object-schema) in which this profile was mentioned as a creator>
|
||||
|
||||
### `GET /api/pulse/profiles/<id=number>/` with optional `?format=json`
|
||||
|
||||
This retrieves a single user profile with the indicated `id` as stored in the database. Any profile can be retrieved using this route even without being authenticated. As a base URL call this returns an HTML page with formatted results, as url with `?format=json` suffix this results a JSON object for use as data input to applications, webpages, etc.
|
||||
|
||||
#### Version 2 - `GET /api/pulse/v2/profiles/<id=number>/` with optional `?format=json`
|
||||
|
||||
The response only contains profile information without any information about entries related to the profile (use [GET /api/pulse/profiles/<id=number>/entries/?...](#get-apipulseprofilesidnumberentries-with-filter-arguments-and-optional-formatjsong) for retrieving the entries).
|
||||
|
||||
#### Version 1 - `GET /api/pulse/v1/profiles/<id=number>/` with optional `?format=json`
|
||||
|
||||
The payload returned by this route also includes an array of entries published (`published_entries`) by the user owning this profile and an array of entries created (`created_entries`) by this profile (as defined by other users when creating entries).
|
||||
This retrieves a single [user profile object](#profile-object-schema) with the indicated `id` as stored in the database. Any profile can be retrieved using this route even without being authenticated. As a base URL call this returns an HTML page with formatted results, as url with `?format=json` suffix this results a JSON object for use as data input to applications, webpages, etc.
|
||||
|
||||
### `GET /api/pulse/profiles/<id=number>/entries/?...` with filter arguments, and optional `?format=json`
|
||||
|
||||
|
@ -361,31 +495,47 @@ This retrieves a list of entries associated with a profile specified by `id`. Th
|
|||
- `?published=true`: Include a list of entries (with their `related_creators`) published by this profile.
|
||||
- `?favorited=true`: Include a list of entries (with their `related_creators`) favorited/bookmarked by this profile.
|
||||
|
||||
If none of the filters are specified, only the number of entries directly associated with the profile will be returned.
|
||||
__NOTE__: If none of the filters are specified, only the number of entries directly associated with the profile will be returned.
|
||||
|
||||
Based on the filter specified, the response payload will accordingly contain a `created`, `published`, and/or `favorited` property, each of whose value is a list of their corresponding entry objects.
|
||||
|
||||
The schema of the entry objects is specified below:
|
||||
|
||||
```
|
||||
{
|
||||
id: <integer: id of the entry>,
|
||||
title: <string: title of the entry>,
|
||||
content_url: <string: external url for the contents of the entry>,
|
||||
thumbnail: <string: url to a thumbnail image for the entry>,
|
||||
is_bookmarked: <boolean: whether this entry is bookmarked for the currently authenticated user>,
|
||||
related_creators: <array: list of related creator objects (see below)>
|
||||
}
|
||||
```
|
||||
|
||||
Depending on the API version specified, the `related_creator` object schema will vary as mentioned [here](#related-creator-object-schema).
|
||||
|
||||
### `GET /api/pulse/profiles/?...` with filter arguments, and optional `format=json`
|
||||
|
||||
The list of profiles known to the system can be queried, but **only** in conjunction with one or more of three query arguments:
|
||||
Returns a list of [user profile objects](#profile-object-schema). This route supports filtering based on properties of profiles and also supports searching for profiles based on `name`.
|
||||
|
||||
__NOTE__: At least one filter or search query from below must be specified, otherwise an empty array is returned in the payload.
|
||||
|
||||
#### Filters and Search Queries
|
||||
|
||||
- `?profile_type=...`: filter the list by profile types `board member`, `fellow`, `grantee`, `plain`, or `staff`.
|
||||
- `?program_type=...`: filter the list by program types `media fellow`, `open web fellow`, `science fellow`, `senior fellow`, or `tech policy fellow`.
|
||||
- `?program_year=...`: filter the list by program year in the range 2015-2019 (inclusive).
|
||||
- `?is_active=<true or false>`: filter profiles by their active state.
|
||||
- `?name=...`: search for profiles by their name. Supports partial and full search matches.
|
||||
|
||||
You can sort these results using the `ordering` query param, passing it either `custom_name` or `program_year` (negated like `-custom_name` to reverse).
|
||||
#### Other Supported Queries
|
||||
|
||||
The resulting payload content differs based on the version of the API you use.
|
||||
|
||||
#### Version 2 - `GET /api/pulse/v2/profiles/?...` with filter arguments, and optional `format=json`
|
||||
|
||||
The response only contains profile information without any information about entries related to the profile (use [GET /api/pulse/profiles/<id=number>/entries/?...](#get-apipulseprofilesidnumberentries-with-filter-arguments-and-optional-formatjsong) for retrieving the entries for each profile individually).
|
||||
|
||||
#### Version 1 - `GET /api/pulse/v1/profiles/?...` with filter arguments, and optional `format=json`
|
||||
|
||||
The payload returned by this route also includes an array of entries published (`published_entries`) by the user owning this profile and an array of entries created (`created_entries`) by this profile (as defined by other users when creating entries).
|
||||
- `?ordering=...` - You can sort these results using the `ordering` query param, passing it either `custom_name` or `program_year` (negated like `-custom_name` for descending order).
|
||||
- `?basic=<true or false>` - This provides a way to only get basic information about profiles. Each profile object in the list will only contain the `id` of the profile and the `name` of the profile. This query can be useful for providing autocomplete options for profiles. __NOTE__ - This query is not compatible with version 1 of the API.
|
||||
|
||||
### `GET /api/pulse/myprofile/` with optional `?format=json`
|
||||
|
||||
This retrieves the **editable** user profile for the currently authenticated user as stored in the database. An unauthenticated user will receive an HTTP 403 Forbidden response if they try to access this route. As a base URL call this returns an HTML page with formatted results, as url with `?format=json` suffix this results a JSON object for use as data input to applications, webpages, etc.
|
||||
This retrieves the **editable** [user profile](#profile-object-schema) for the currently authenticated user without the `name` and `my_profile` properties in the payload. An unauthenticated user will receive an HTTP 403 Forbidden response if they try to access this route. As a base URL call this returns an HTML page with formatted results, as url with `?format=json` suffix this results a JSON object for use as data input to applications, webpages, etc.
|
||||
|
||||
### `PUT /api/pulse/myprofile/`
|
||||
|
||||
|
|
|
@ -1,14 +0,0 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from .models import Creator
|
||||
|
||||
|
||||
class CreatorAdmin(admin.ModelAdmin):
|
||||
search_fields = (
|
||||
'name',
|
||||
'profile__custom_name',
|
||||
'profile__related_user__name',
|
||||
)
|
||||
|
||||
|
||||
admin.site.register(Creator, CreatorAdmin)
|
|
@ -4,26 +4,17 @@ Create fake creators that are not pulse users for local development and Heroku's
|
|||
|
||||
from factory import (
|
||||
DjangoModelFactory,
|
||||
Faker,
|
||||
Iterator,
|
||||
)
|
||||
|
||||
from pulseapi.creators.models import Creator, OrderedCreatorRecord
|
||||
from pulseapi.profiles.models import UserProfile
|
||||
from pulseapi.creators.models import EntryCreator
|
||||
|
||||
|
||||
# Create creators that are not pulse users
|
||||
class CreatorFactory(DjangoModelFactory):
|
||||
# Create a relation between a random profile and a random entry
|
||||
class EntryCreatorFactory(DjangoModelFactory):
|
||||
|
||||
class Meta:
|
||||
model = Creator
|
||||
model = EntryCreator
|
||||
|
||||
name = Faker('name')
|
||||
|
||||
|
||||
# Create a relation between a random creator and a random entry
|
||||
class OrderedCreatorRecordFactory(DjangoModelFactory):
|
||||
|
||||
class Meta:
|
||||
model = OrderedCreatorRecord
|
||||
|
||||
creator = Iterator(Creator.objects.all())
|
||||
profile = Iterator(UserProfile.objects.all())
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2018-03-18 18:08
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('profiles', '0017_auto_20180213_1202'),
|
||||
('entries', '0021_auto_20180306_1045'),
|
||||
('creators', '0011_auto_20171025_1917'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='EntryCreator',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('entry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='related_entry_creators', to='entries.Entry')),
|
||||
('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='related_entry_creators', to='profiles.UserProfile')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Entry Creators',
|
||||
},
|
||||
),
|
||||
migrations.AlterOrderWithRespectTo(
|
||||
name='entrycreator',
|
||||
order_with_respect_to='entry',
|
||||
),
|
||||
]
|
|
@ -0,0 +1,29 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2018-04-10 20:55
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('entries', '0022_auto_20180326_2211'),
|
||||
('profiles', '0017_auto_20180213_1202'),
|
||||
('creators', '0012_auto_20180318_1808'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name='entrycreator',
|
||||
unique_together=set([('entry', 'profile')]),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='entrycreator',
|
||||
index=models.Index(fields=['entry', '_order'], name='creators_en_entry_i_b199e7_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='entrycreator',
|
||||
index=models.Index(fields=['entry', 'profile'], name='creators_en_entry_i_968f03_idx'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,34 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2018-04-16 22:52
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('creators', '0013_auto_20180410_2055'),
|
||||
('profiles', '0018_replace_creators_with_entrycreators')
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='creator',
|
||||
name='profile',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='orderedcreatorrecord',
|
||||
name='creator',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='orderedcreatorrecord',
|
||||
name='entry',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='Creator',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='OrderedCreatorRecord',
|
||||
),
|
||||
]
|
|
@ -3,130 +3,38 @@ The creator field for an entry. Can be empty, just a name,
|
|||
or linked to a pulse user
|
||||
"""
|
||||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
|
||||
class CreatorQuerySet(models.query.QuerySet):
|
||||
class EntryCreator(models.Model):
|
||||
"""
|
||||
A queryset for creators which returns all creators by name
|
||||
"""
|
||||
|
||||
def public(self):
|
||||
"""
|
||||
Returns all creators. Mainly a starting point for the search
|
||||
query for use with suggestions
|
||||
"""
|
||||
return self
|
||||
|
||||
def slug(self, slug):
|
||||
return self.filter(name=slug)
|
||||
|
||||
|
||||
class Creator(models.Model):
|
||||
"""
|
||||
Person recognized as the creator of the thing an entry links out to.
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=140,
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
profile = models.OneToOneField(
|
||||
'profiles.UserProfile',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='related_creator',
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
objects = CreatorQuerySet.as_manager()
|
||||
|
||||
@property
|
||||
def creator_name(self):
|
||||
return self.profile.name if self.profile else self.name
|
||||
|
||||
def clean(self):
|
||||
"""
|
||||
We provide custom validation for the model to make sure that
|
||||
either a profile or a name is provided for the creator since
|
||||
a creator without either is not useful.
|
||||
"""
|
||||
profile = self.profile
|
||||
name = self.name
|
||||
|
||||
if profile is None and name is None:
|
||||
raise ValidationError(_('Either a profile or a name must be specified for this creator.'))
|
||||
|
||||
if profile and name:
|
||||
# In case both name and profile are provided, we clear the
|
||||
# name so that the profile's name takes precedence. We do this
|
||||
# so that if a profile's name is changed, the creator reflects
|
||||
# the updated name.
|
||||
self.name = None
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Django does not automatically perform model validation on save due to
|
||||
backwards compatibility. Since we have custom validation, we manually
|
||||
call the validator (which calls our clean function) on save.
|
||||
"""
|
||||
self.full_clean()
|
||||
super(Creator, self).save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return '{name}{has_profile}'.format(
|
||||
name=self.creator_name,
|
||||
has_profile=' (no profile)' if not self.profile else ''
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Creator"
|
||||
ordering = [
|
||||
'name'
|
||||
]
|
||||
|
||||
|
||||
class OrderedCreatorRecord(models.Model):
|
||||
"""
|
||||
This model records creators for entries,
|
||||
with an explicit ordering field so that
|
||||
we can reconstruct the order in which
|
||||
the list of creators was submitted.
|
||||
A bridge model to describe a relationship between profiles
|
||||
as creators and entries.
|
||||
"""
|
||||
entry = models.ForeignKey(
|
||||
'entries.Entry',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='related_creators'
|
||||
related_name='related_entry_creators',
|
||||
)
|
||||
|
||||
creator = models.ForeignKey(
|
||||
'creators.Creator',
|
||||
profile = models.ForeignKey(
|
||||
'profiles.UserProfile',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='related_entries',
|
||||
null=True
|
||||
related_name='related_entry_creators',
|
||||
)
|
||||
|
||||
# Rather than an "order" field, we rely on
|
||||
# the auto-generated `id` field, which is
|
||||
# an auto-incrementing value that does not
|
||||
# indicate when an entry was created, but does
|
||||
# indicate the temporal order in which records
|
||||
# were added into the database, allowing us
|
||||
# to sort the list based on insertion-ordering.
|
||||
|
||||
def __str__(self):
|
||||
return 'ordered creator for "{entry}" by [{creator}:{order}]'.format(
|
||||
entry=self.entry,
|
||||
creator=self.creator,
|
||||
order=self.id
|
||||
return 'Creator {creator} for "{entry}"'.format(
|
||||
entry=self.entry.title,
|
||||
creator=self.profile.name,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Ordered creator record"
|
||||
|
||||
# Ensure that these records are always ordred based on
|
||||
# row ordering in the database.
|
||||
ordering = [
|
||||
'pk',
|
||||
verbose_name = 'Entry Creators'
|
||||
# This meta option creates an _order column in the table
|
||||
# See https://docs.djangoproject.com/en/1.11/ref/models/options/#order-with-respect-to for more details
|
||||
order_with_respect_to = 'entry'
|
||||
indexes = [
|
||||
models.Index(fields=['entry', '_order'], name='uk_entrycreator_entryid_order'),
|
||||
models.Index(fields=['entry', 'profile'], name='uk_entrycreator_entry_profile')
|
||||
]
|
||||
unique_together = ('entry', 'profile',)
|
||||
|
|
|
@ -3,80 +3,130 @@ from rest_framework.exceptions import ValidationError
|
|||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
from pulseapi.creators.models import Creator, OrderedCreatorRecord
|
||||
from pulseapi.profiles.models import UserProfile
|
||||
from pulseapi.entries.models import Entry
|
||||
|
||||
|
||||
class CreatorSerializer(serializers.ModelSerializer):
|
||||
def serialize_profile_as_creator(profile):
|
||||
return {
|
||||
'name': profile.name,
|
||||
'profile_id': profile.id
|
||||
}
|
||||
|
||||
|
||||
def serialize_profile_as_v1_creator(profile):
|
||||
serialized_profile = serialize_profile_as_creator(profile)
|
||||
serialized_profile['creator_id'] = profile.id # we include this property only for backwards-compatibility
|
||||
return serialized_profile
|
||||
|
||||
|
||||
def get_or_create_userprofile(data, id_key):
|
||||
"""
|
||||
Serializes creators
|
||||
Deserialize data into a `UserProfile` object.
|
||||
The `data` is checked for a value corresponding to the id_key.
|
||||
If it exists, we get the corresponding `UserProfile` object, otherwise
|
||||
we create a new `UserProfile` object with the name specified.
|
||||
We don't save the instance to the database and that is left to
|
||||
the calling function to save the instance.
|
||||
|
||||
Returns a dictionary with two keys - `object` and `created`,
|
||||
where `object` is the retrieved or created `UserProfile` instance and
|
||||
`created` is a boolean specifying whether a new instance was created
|
||||
"""
|
||||
profile_id = data.get(id_key)
|
||||
name = data.get('name')
|
||||
|
||||
if not profile_id and not name:
|
||||
raise ValidationError(
|
||||
detail=_('A creator/profile id or a name must be provided.'),
|
||||
code='missing data',
|
||||
)
|
||||
|
||||
if profile_id:
|
||||
try:
|
||||
return UserProfile.objects.get(id=profile_id), False
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError(
|
||||
detail=_('No profile exists for the given id {id}.'.format(id=profile_id)),
|
||||
code='invalid',
|
||||
)
|
||||
|
||||
return UserProfile(custom_name=name), True
|
||||
|
||||
|
||||
def get_entry(data):
|
||||
entry_id = data.get('entry_id')
|
||||
if not entry_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
return Entry.objects.get(id=entry_id)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError(
|
||||
detail=_('No entry exists for the given id {id}.'.format(id=entry_id)),
|
||||
code='invalid',
|
||||
)
|
||||
|
||||
|
||||
def deserialize_entry_creator(data, profile_id_key):
|
||||
profile, created = get_or_create_userprofile(data, profile_id_key)
|
||||
entry = get_entry(data)
|
||||
|
||||
entry_creator_data = {
|
||||
'profile': profile,
|
||||
'profile_committed': not created
|
||||
}
|
||||
if entry:
|
||||
entry_creator_data['entry'] = entry
|
||||
|
||||
return entry_creator_data
|
||||
|
||||
|
||||
class CreatorSerializer(serializers.BaseSerializer):
|
||||
"""
|
||||
Read-only serializer that serializes creators (which are actually profile objects)
|
||||
This serializer only exists for backwards-compatibility and is disfavored
|
||||
over pulseapi.profiles.serializers.UserProfileBasicSerializer
|
||||
"""
|
||||
def to_representation(self, instance):
|
||||
id = instance.profile.id if instance.profile else False
|
||||
return {
|
||||
'name': instance.creator_name,
|
||||
'creator_id': instance.id,
|
||||
'profile_id': id
|
||||
}
|
||||
|
||||
class Meta:
|
||||
"""
|
||||
Meta class. Because it's required by ModelSerializer
|
||||
"""
|
||||
model = Creator
|
||||
fields = ('name', 'profile',)
|
||||
return serialize_profile_as_v1_creator(instance)
|
||||
|
||||
|
||||
class EntryOrderedCreatorSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
We use this serializer to serialize creators that are related to entries.
|
||||
While the model is set to `OrderedCreatorRecord`, we only do that since we
|
||||
inherit from `ModelSerializer`, and the output of serialization/descerialization
|
||||
is actually a `Creator` and not an `OrderedCreatorRecord`. We do this because
|
||||
for an entry, an `OrderedCreatorRecord` object is not useful while a `Creator`
|
||||
object is really what we want.
|
||||
"""
|
||||
class RelatedEntryCreatorField(serializers.RelatedField):
|
||||
def to_representation(self, instance):
|
||||
"""
|
||||
Serialize an `OrderedCreatorRecord` object into something meaningful
|
||||
"""
|
||||
creator = instance.creator
|
||||
|
||||
return {
|
||||
'creator_id': creator.id,
|
||||
'profile_id': creator.profile.id if creator.profile else None,
|
||||
'name': creator.creator_name
|
||||
}
|
||||
return serialize_profile_as_creator(instance.profile)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
"""
|
||||
Deserialize data passed in into a `Creator` object that can be used to
|
||||
create an `OrderedCreatorRecord` object.
|
||||
If an `id` is provided, we get the corresponding `Creator` object, otherwise
|
||||
we create a new `Creator` object with the name specified but don't actually
|
||||
save to the database so that we don't create stale values if something
|
||||
fails elsewhere (for e.g. in the `create` method). The `create`/`update`
|
||||
methods are responsible for saving this object to the database.
|
||||
Returns a dictionary:
|
||||
{
|
||||
'profile': deserialized instance of `UserProfile`
|
||||
'profile_committed': boolean indicating whether the profile
|
||||
instance is from the database or needs to be committed
|
||||
}
|
||||
This dictionary will also contain an `entry` if a valid `entry_id`
|
||||
was passed in with the `data`
|
||||
|
||||
Expects either a `profile_id` or a `name` to exist in the `data`.
|
||||
"""
|
||||
has_creator_id = 'creator_id' in data and data['creator_id']
|
||||
has_name = 'name' in data and data['name']
|
||||
return deserialize_entry_creator(data, 'profile_id')
|
||||
|
||||
if not has_creator_id and not has_name:
|
||||
raise ValidationError(
|
||||
detail=_('A creator id or a name must be provided.'),
|
||||
code='missing data',
|
||||
)
|
||||
|
||||
if has_creator_id:
|
||||
try:
|
||||
return Creator.objects.get(id=data['creator_id'])
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError(
|
||||
detail=_('No creator exists for the given id {id}.'.format(id=data['creator_id'])),
|
||||
code='invalid',
|
||||
)
|
||||
class RelatedEntryCreatorV1Field(serializers.RelatedField):
|
||||
def to_representation(self, instance):
|
||||
return serialize_profile_as_v1_creator(instance.profile)
|
||||
|
||||
return Creator(name=data['name'])
|
||||
def to_internal_value(self, data):
|
||||
"""
|
||||
Returns a dictionary:
|
||||
{
|
||||
'profile': deserialized instance of `UserProfile`
|
||||
'profile_committed': boolean indicating whether the profile
|
||||
instance is from the database or needs to be committed
|
||||
}
|
||||
This dictionary will also contain an `entry` if a valid `entry_id`
|
||||
was passed in with the `data`
|
||||
|
||||
class Meta:
|
||||
model = OrderedCreatorRecord
|
||||
fields = '__all__'
|
||||
Expects either a `creator_id` or a `name` to exist in the `data`.
|
||||
"""
|
||||
return deserialize_entry_creator(data, 'creator_id')
|
||||
|
|
|
@ -1,57 +1,48 @@
|
|||
import json
|
||||
from urllib.parse import quote
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.conf import settings
|
||||
from django.http.request import HttpRequest
|
||||
from rest_framework.request import Request
|
||||
|
||||
from pulseapi.profiles.test_models import UserProfileFactory
|
||||
from pulseapi.creators.models import Creator
|
||||
from pulseapi.profiles.models import UserProfile
|
||||
from pulseapi.creators.serializers import CreatorSerializer
|
||||
from pulseapi.creators.views import CreatorsPagination
|
||||
from pulseapi.tests import PulseStaffTestCase
|
||||
|
||||
|
||||
class TestCreatorViews(PulseStaffTestCase):
|
||||
def test_get_creator_list(self):
|
||||
"""Make sure we can get a list of creators"""
|
||||
creatorList = self.client.get('/api/pulse/creators/')
|
||||
self.assertEqual(creatorList.status_code, 200)
|
||||
class TestEntryCreatorViews(PulseStaffTestCase):
|
||||
def test_get_creator_list_v1(self):
|
||||
"""Make sure we can get a list of creators for v1"""
|
||||
response = self.client.get(reverse('creators-list'))
|
||||
page = CreatorsPagination()
|
||||
expected_data = CreatorSerializer(
|
||||
page.paginate_queryset(
|
||||
UserProfile.objects.all().order_by('id'),
|
||||
request=Request(request=HttpRequest()) # mock request to satisfy the required arguments
|
||||
),
|
||||
many=True
|
||||
).data
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertListEqual(response.data['results'], expected_data)
|
||||
|
||||
def test_get_creator_list_404_for_v2(self):
|
||||
"""Make sure we get a 404 if we access the creator list using v2"""
|
||||
response = self.client.get(reverse(
|
||||
'creators-list',
|
||||
args=[settings.API_VERSIONS['version_2'] + '/']
|
||||
))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_creator_filtering(self):
|
||||
"""search creators, for autocomplete"""
|
||||
last = Creator.objects.last()
|
||||
search = last.creator_name
|
||||
profile = UserProfile.objects.last()
|
||||
|
||||
url = '/api/pulse/creators/?name={search}'.format(
|
||||
search=quote(search)
|
||||
)
|
||||
response = self.client.get('{creator_url}?name={search}'.format(
|
||||
creator_url=reverse('creators-list'),
|
||||
search=quote(profile.name)
|
||||
))
|
||||
response_creator = json.loads(str(response.content, 'utf-8'))['results'][0]
|
||||
|
||||
creatorList = json.loads(
|
||||
str(self.client.get(url).content, 'utf-8')
|
||||
)
|
||||
|
||||
rest_result = creatorList['results'][0]
|
||||
|
||||
self.assertEqual(last.id, rest_result['creator_id'])
|
||||
|
||||
|
||||
class TestCreatorModel(PulseStaffTestCase):
|
||||
def test_require_profile_or_name_on_save(self):
|
||||
"""
|
||||
Make sure that we aren't allowed to save without specifying
|
||||
a profile or a name
|
||||
"""
|
||||
creator = Creator()
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
creator.save()
|
||||
|
||||
def test_auto_create_creator_on_creating_profile(self):
|
||||
"""
|
||||
Make sure that when a profile is created a creator associated with
|
||||
that profile is created whose name is set to None and whose
|
||||
creator_name is the same as the profile name
|
||||
"""
|
||||
profile = UserProfileFactory()
|
||||
profile.save()
|
||||
|
||||
creator_from_db = Creator.objects.get(profile=profile)
|
||||
|
||||
self.assertIsNone(creator_from_db.name)
|
||||
self.assertEqual(creator_from_db.creator_name, profile.name)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(profile.id, response_creator['creator_id'])
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
from itertools import chain
|
||||
from django.db.models import Q
|
||||
|
||||
from django.conf import settings
|
||||
from rest_framework import filters
|
||||
from rest_framework.generics import ListAPIView
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework import exceptions
|
||||
|
||||
from pulseapi.profiles.models import UserProfile
|
||||
from pulseapi.creators.serializers import CreatorSerializer
|
||||
from pulseapi.creators.models import Creator
|
||||
|
||||
|
||||
class CreatorsPagination(PageNumberPagination):
|
||||
|
@ -26,10 +27,9 @@ class FilterCreatorNameBackend(filters.BaseFilterBackend):
|
|||
if not search_term:
|
||||
return queryset
|
||||
|
||||
own_name = Q(name__istartswith=search_term)
|
||||
profile_custom = Q(profile__custom_name__istartswith=search_term)
|
||||
profile_name = Q(profile__related_user__name__istartswith=search_term)
|
||||
istartswith_filter = own_name | profile_custom | profile_name
|
||||
profile_custom = Q(custom_name__istartswith=search_term)
|
||||
profile_name = Q(related_user__name__istartswith=search_term)
|
||||
istartswith_filter = profile_custom | profile_name
|
||||
qs = queryset.filter(istartswith_filter)
|
||||
|
||||
# If the number of results returned is less than the allowed
|
||||
|
@ -40,10 +40,9 @@ class FilterCreatorNameBackend(filters.BaseFilterBackend):
|
|||
flen = len(qs)
|
||||
|
||||
if flen < page_size:
|
||||
own_name = Q(name__icontains=search_term)
|
||||
profile_custom = Q(profile__custom_name__icontains=search_term)
|
||||
profile_name = Q(profile__related_user__name__icontains=search_term)
|
||||
icontains_filter = own_name | profile_custom | profile_name
|
||||
profile_custom = Q(custom_name__icontains=search_term)
|
||||
profile_name = Q(related_user__name__icontains=search_term)
|
||||
icontains_filter = profile_custom | profile_name
|
||||
icontains_qs = queryset.filter(icontains_filter).exclude(istartswith_filter)
|
||||
# make sure we keep our exact matches at the top of the result list
|
||||
qs = list(chain(qs, icontains_qs))
|
||||
|
@ -55,6 +54,11 @@ class CreatorListView(ListAPIView):
|
|||
"""
|
||||
A view that permits a GET to allow listing all creators in the database
|
||||
|
||||
Note
|
||||
----
|
||||
This view only exists for backwards-compatibility (version 1). Creators no longer exist as independent models
|
||||
and hence the pulseapi.profiles.views.UserProfileListAPIView should be used instead.
|
||||
|
||||
**Route** - `/creators`
|
||||
|
||||
#Query Parameters -
|
||||
|
@ -62,7 +66,7 @@ class CreatorListView(ListAPIView):
|
|||
- ?name= - a partial match filter based on the start of the creator name.
|
||||
|
||||
"""
|
||||
queryset = Creator.objects.all()
|
||||
queryset = UserProfile.objects.all().order_by('id')
|
||||
pagination_class = CreatorsPagination
|
||||
serializer_class = CreatorSerializer
|
||||
|
||||
|
@ -74,3 +78,14 @@ class CreatorListView(ListAPIView):
|
|||
search_fields = (
|
||||
'^name',
|
||||
)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
if request.version == settings.API_VERSIONS['version_1']:
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
raise exceptions.NotFound(
|
||||
'The {path} route has been deprecated and is no longer supported in version {version}'.format(
|
||||
path=request.path,
|
||||
version=request.version
|
||||
)
|
||||
)
|
||||
|
|
|
@ -82,7 +82,7 @@ class EntryAdmin(admin.ModelAdmin):
|
|||
return instance.bookmarked_by.count()
|
||||
|
||||
def creators(self, instance):
|
||||
related_creator_names = [c.creator.creator_name for c in instance.related_creators.all()]
|
||||
related_creator_names = [c.profile.name for c in instance.related_entry_creators.all()]
|
||||
if not related_creator_names:
|
||||
return '-'
|
||||
return ', '.join(related_creator_names)
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
"""Main entry data"""
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.html import format_html
|
||||
|
||||
from pulseapi.tags.models import Tag
|
||||
from pulseapi.issues.models import Issue
|
||||
from pulseapi.helptypes.models import HelpType
|
||||
from pulseapi.users.models import EmailUser
|
||||
from django.utils import timezone
|
||||
from django.utils.html import format_html
|
||||
|
||||
|
||||
def entry_thumbnail_path(instance, filename):
|
||||
|
@ -76,7 +76,7 @@ class EntryQuerySet(models.query.QuerySet):
|
|||
'bookmarked_by__profile__related_user',
|
||||
'published_by__profile',
|
||||
'moderation_state',
|
||||
'related_creators__creator__profile__related_user',
|
||||
'related_entry_creators__profile__related_user',
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -2,13 +2,28 @@
|
|||
from rest_framework import serializers
|
||||
from django.utils.encoding import smart_text
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import transaction
|
||||
from django.db.utils import IntegrityError
|
||||
|
||||
from pulseapi.entries.models import Entry, ModerationState
|
||||
from pulseapi.tags.models import Tag
|
||||
from pulseapi.issues.models import Issue
|
||||
from pulseapi.helptypes.models import HelpType
|
||||
from pulseapi.creators.models import Creator, OrderedCreatorRecord
|
||||
from pulseapi.creators.serializers import EntryOrderedCreatorSerializer
|
||||
from pulseapi.creators.models import EntryCreator
|
||||
from pulseapi.creators.serializers import (
|
||||
RelatedEntryCreatorV1Field,
|
||||
RelatedEntryCreatorField,
|
||||
)
|
||||
|
||||
|
||||
def associate_entry_with_creator_data(entry, creator_data=[], user=None):
|
||||
if user and entry.published_by_creator:
|
||||
creator_data.append({'profile': user.profile})
|
||||
|
||||
for data in creator_data:
|
||||
if not data.pop('profile_committed', True):
|
||||
data.profile.save()
|
||||
EntryCreator.objects.create(entry=entry, **data)
|
||||
|
||||
|
||||
class CreatableSlugRelatedField(serializers.SlugRelatedField):
|
||||
|
@ -64,6 +79,7 @@ class EntryBaseSerializer(serializers.ModelSerializer):
|
|||
class Meta:
|
||||
model = Entry
|
||||
read_only_fields = fields = (
|
||||
'id',
|
||||
'title',
|
||||
'content_url',
|
||||
'thumbnail',
|
||||
|
@ -71,13 +87,35 @@ class EntryBaseSerializer(serializers.ModelSerializer):
|
|||
)
|
||||
|
||||
|
||||
class EntryWithV1CreatorsBaseSerializer(EntryBaseSerializer):
|
||||
related_creators = RelatedEntryCreatorV1Field(
|
||||
queryset=EntryCreator.objects.all(),
|
||||
source='related_entry_creators',
|
||||
required=False,
|
||||
many=True,
|
||||
)
|
||||
|
||||
class Meta(EntryBaseSerializer.Meta):
|
||||
read_only_fields = fields = EntryBaseSerializer.Meta.fields + ('related_creators',)
|
||||
|
||||
|
||||
class EntryWithCreatorsBaseSerializer(EntryBaseSerializer):
|
||||
related_creators = RelatedEntryCreatorField(
|
||||
queryset=EntryCreator.objects.all(),
|
||||
source='related_entry_creators',
|
||||
required=False,
|
||||
many=True,
|
||||
)
|
||||
|
||||
class Meta(EntryBaseSerializer.Meta):
|
||||
read_only_fields = fields = EntryBaseSerializer.Meta.fields + ('related_creators',)
|
||||
|
||||
|
||||
class EntrySerializer(EntryBaseSerializer):
|
||||
"""
|
||||
Serializes an entry with embeded information including
|
||||
list of tags, categories and links associated with that entry
|
||||
as simple strings. It also includes a list of hyperlinks to events
|
||||
that are associated with this entry as well as hyperlinks to users
|
||||
that are involved with the entry
|
||||
list of tags, issues and help types associated with that entry
|
||||
as simple strings.
|
||||
"""
|
||||
|
||||
tags = CreatableSlugRelatedField(
|
||||
|
@ -101,11 +139,6 @@ class EntrySerializer(EntryBaseSerializer):
|
|||
required=False
|
||||
)
|
||||
|
||||
related_creators = EntryOrderedCreatorSerializer(
|
||||
required=False,
|
||||
many=True,
|
||||
)
|
||||
|
||||
# overrides 'published_by' for REST purposes
|
||||
# as we don't want to expose any user's email address
|
||||
published_by = serializers.SlugRelatedField(
|
||||
|
@ -129,32 +162,6 @@ class EntrySerializer(EntryBaseSerializer):
|
|||
"""
|
||||
return instance.bookmarked_by.count()
|
||||
|
||||
def create(self, validated_data):
|
||||
"""
|
||||
We override the create method to make sure we save related creators
|
||||
as well and setup the relationship with the created entry.
|
||||
"""
|
||||
related_creators = validated_data.pop('related_creators', [])
|
||||
entry = super(EntrySerializer, self).create(validated_data)
|
||||
profile = None
|
||||
|
||||
if 'request' in self.context and hasattr(self.context['request'], 'user'):
|
||||
profile = self.context['request'].user.profile
|
||||
|
||||
if entry.published_by_creator:
|
||||
self_creator, created = Creator.objects.get_or_create(profile=profile)
|
||||
if self_creator not in related_creators:
|
||||
related_creators.append(self_creator)
|
||||
|
||||
for creator in related_creators:
|
||||
creator.save()
|
||||
OrderedCreatorRecord.objects.create(
|
||||
creator=creator,
|
||||
entry=entry,
|
||||
)
|
||||
|
||||
return entry
|
||||
|
||||
class Meta:
|
||||
"""
|
||||
Meta class. Because
|
||||
|
@ -163,3 +170,49 @@ class EntrySerializer(EntryBaseSerializer):
|
|||
exclude = (
|
||||
'internal_notes',
|
||||
)
|
||||
|
||||
|
||||
class EntrySerializerWithCreators(EntrySerializer):
|
||||
related_creators = RelatedEntryCreatorField(
|
||||
queryset=EntryCreator.objects.all(),
|
||||
source='related_entry_creators',
|
||||
required=False,
|
||||
many=True,
|
||||
)
|
||||
|
||||
def create(self, validated_data):
|
||||
"""
|
||||
We override the create method to make sure we save related creators
|
||||
as well and setup the relationship with the created entry.
|
||||
"""
|
||||
user = self.context.get('user')
|
||||
creator_data = validated_data.pop('related_entry_creators', [])
|
||||
entry = super().create(validated_data)
|
||||
|
||||
if user and entry.published_by_creator:
|
||||
creator_data.append({'profile': user.profile})
|
||||
|
||||
for data in creator_data:
|
||||
if not data.pop('profile_committed', True):
|
||||
data['profile'].save()
|
||||
try:
|
||||
with transaction.atomic():
|
||||
EntryCreator.objects.create(entry=entry, **data)
|
||||
except IntegrityError:
|
||||
# We ignore duplicate key violations so that only single
|
||||
# relations exist between entries and profiles.
|
||||
# This exception might be thrown if the published_by_creator
|
||||
# field is True and the provided user's profile is also included
|
||||
# in the `related_creators`
|
||||
pass
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
class EntrySerializerWithV1Creators(EntrySerializerWithCreators):
|
||||
related_creators = RelatedEntryCreatorV1Field(
|
||||
queryset=EntryCreator.objects.all(),
|
||||
source='related_entry_creators',
|
||||
required=False,
|
||||
many=True,
|
||||
)
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import json
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from pulseapi.entries.models import Entry, ModerationState
|
||||
from pulseapi.creators.models import EntryCreator
|
||||
from pulseapi.tests import PulseMemberTestCase
|
||||
|
||||
|
||||
|
@ -21,10 +23,10 @@ class TestMemberEntryView(PulseMemberTestCase):
|
|||
id = str(responseobj['id'])
|
||||
|
||||
getresponse = self.client.get('/api/pulse/entries/' + id, follow=True)
|
||||
getListresponse = json.loads(
|
||||
get_list_response = json.loads(
|
||||
str(self.client.get('/api/pulse/entries/').content, 'utf-8')
|
||||
)
|
||||
results = getListresponse['results']
|
||||
results = get_list_response['results']
|
||||
|
||||
self.assertEqual(len(results), 2)
|
||||
self.assertEqual(getresponse.status_code, 404)
|
||||
|
@ -63,61 +65,41 @@ class TestMemberEntryView(PulseMemberTestCase):
|
|||
|
||||
# verify that unauthenticated users get a status 200 response
|
||||
self.client.logout()
|
||||
bookmarkResponse = self.client.get('/api/pulse/entries/bookmarks/')
|
||||
self.assertEqual(bookmarkResponse.status_code, 200)
|
||||
bookmark_response = self.client.get('/api/pulse/entries/bookmarks/')
|
||||
self.assertEqual(bookmark_response.status_code, 200)
|
||||
|
||||
bookmarkJson = json.loads(str(bookmarkResponse.content, 'utf-8'))
|
||||
bookmark_json = json.loads(str(bookmark_response.content, 'utf-8'))
|
||||
|
||||
# verify that data returned has the following properties and that 'count' is 0
|
||||
self.assertEqual('count' in bookmarkJson, True)
|
||||
self.assertEqual('previous' in bookmarkJson, True)
|
||||
self.assertEqual('next' in bookmarkJson, True)
|
||||
self.assertEqual('results' in bookmarkJson, True)
|
||||
self.assertEqual(len(bookmarkJson), 4)
|
||||
self.assertEqual(bookmarkJson['count'], 0)
|
||||
self.assertEqual('count' in bookmark_json, True)
|
||||
self.assertEqual('previous' in bookmark_json, True)
|
||||
self.assertEqual('next' in bookmark_json, True)
|
||||
self.assertEqual('results' in bookmark_json, True)
|
||||
self.assertEqual(len(bookmark_json), 4)
|
||||
self.assertEqual(bookmark_json['count'], 0)
|
||||
|
||||
def test_creator_ordering(self):
|
||||
"""
|
||||
Verify that posting entries preserves the order in which creators
|
||||
were passed to the system.
|
||||
"""
|
||||
|
||||
related_creators = [
|
||||
{'name': 'First Creator'},
|
||||
{'name': 'Second Creator'},
|
||||
{'name': 'Third Creator'},
|
||||
]
|
||||
payload = self.generatePostPayload(data={
|
||||
'title': 'title test_creator_ordering',
|
||||
'content_url': 'http://example.org/test_creator_ordering',
|
||||
'creators': [
|
||||
'First Creator',
|
||||
'Second Creator',
|
||||
]
|
||||
'related_creators': related_creators
|
||||
})
|
||||
|
||||
postresponse = self.client.post('/api/pulse/entries/', payload)
|
||||
content = str(postresponse.content, 'utf-8')
|
||||
response = json.loads(content)
|
||||
id = int(response['id'])
|
||||
entry = Entry.objects.get(id=id)
|
||||
creators = [c.creator for c in entry.related_creators.all()]
|
||||
response = self.client.post(reverse('entries-list'), payload)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.assertEqual(creators[0].name, 'First Creator')
|
||||
self.assertEqual(creators[1].name, 'Second Creator')
|
||||
entry_id = int(json.loads(str(response.content, 'utf-8'))['id'])
|
||||
entry_creators = EntryCreator.objects.filter(entry__id=entry_id).select_related('profile')
|
||||
self.assertEqual(len(entry_creators), len(related_creators))
|
||||
|
||||
payload = self.generatePostPayload(data={
|
||||
'title': 'title test_creator_ordering',
|
||||
'content_url': 'http://example.org/test_creator_ordering',
|
||||
'creators': [
|
||||
# note that this is a different creator ordering
|
||||
'Second Creator',
|
||||
'First Creator',
|
||||
]
|
||||
})
|
||||
|
||||
postresponse = self.client.post('/api/pulse/entries/', payload)
|
||||
content = str(postresponse.content, 'utf-8')
|
||||
response = json.loads(content)
|
||||
id = int(response['id'])
|
||||
entry = Entry.objects.get(id=id)
|
||||
creators = [c.creator for c in entry.related_creators.all()]
|
||||
|
||||
# the same ordering should be reflected in the creator list
|
||||
self.assertEqual(creators[0].name, 'Second Creator')
|
||||
self.assertEqual(creators[1].name, 'First Creator')
|
||||
for creator, entry_creator in zip(related_creators, entry_creators):
|
||||
self.assertEqual(entry_creator.profile.custom_name, creator['name'])
|
||||
|
|
|
@ -1,16 +1,51 @@
|
|||
import json
|
||||
|
||||
from math import ceil
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.db.models import Q
|
||||
from django.conf import settings
|
||||
from django.test.client import MULTIPART_CONTENT
|
||||
from django.http.request import HttpRequest
|
||||
from rest_framework import status
|
||||
from rest_framework.request import Request
|
||||
|
||||
from pulseapi.creators.models import Creator, OrderedCreatorRecord
|
||||
from pulseapi.creators.models import EntryCreator
|
||||
from pulseapi.profiles.models import UserProfile
|
||||
from pulseapi.entries.models import Entry, ModerationState
|
||||
from pulseapi.entries.serializers import EntrySerializer
|
||||
from pulseapi.entries.serializers import (
|
||||
EntrySerializerWithV1Creators,
|
||||
EntrySerializerWithCreators,
|
||||
)
|
||||
from pulseapi.entries.views import EntriesPagination
|
||||
from pulseapi.tests import PulseStaffTestCase
|
||||
|
||||
|
||||
def run_test_entry_creators(test_case, api_version=None, creator_id_key='creator_id'):
|
||||
creators = [
|
||||
{'name': 'Pomax'},
|
||||
{creator_id_key: UserProfile.objects.last().id},
|
||||
{'name': 'Alan'}
|
||||
]
|
||||
payload = {
|
||||
'title': 'title test_post_entry_with_mixed_creators',
|
||||
'description': 'description test_post_entry_with_mixed_creators',
|
||||
'related_creators': creators,
|
||||
}
|
||||
url = reverse('entries-list', args=[api_version + '/']) if api_version else reverse('entries-list')
|
||||
response = test_case.client.post(
|
||||
url,
|
||||
data=test_case.generatePostPayload(data=payload)
|
||||
)
|
||||
test_case.assertEqual(response.status_code, 200)
|
||||
|
||||
entry_id = int(json.loads(str(response.content, 'utf-8'))['id'])
|
||||
entry_creators = EntryCreator.objects.filter(entry__id=entry_id).select_related('profile')
|
||||
|
||||
for creator, entry_creator in zip(creators, entry_creators):
|
||||
if creator.get(creator_id_key):
|
||||
test_case.assertEqual(entry_creator.profile.id, creator[creator_id_key])
|
||||
else:
|
||||
test_case.assertEqual(entry_creator.profile.custom_name, creator['name'])
|
||||
|
||||
|
||||
class TestEntryView(PulseStaffTestCase):
|
||||
def test_force_json(self):
|
||||
"""
|
||||
|
@ -106,7 +141,10 @@ class TestEntryView(PulseStaffTestCase):
|
|||
'internal_notes': 'Some internal notes',
|
||||
'featured': True,
|
||||
'issues': ['Decentralization'],
|
||||
'creators': ['Pomax', 'Alan']
|
||||
'related_creators': [
|
||||
{'name': 'Pomax'},
|
||||
{'name': 'Alan'}
|
||||
]
|
||||
}
|
||||
postresponse = self.client.post(
|
||||
'/api/pulse/entries/',
|
||||
|
@ -128,7 +166,10 @@ class TestEntryView(PulseStaffTestCase):
|
|||
'internal_notes': 'Some internal notes',
|
||||
'featured': True,
|
||||
'issues': ['Decentralization'],
|
||||
'creators': ['Pomax', 'Alan']
|
||||
'related_creators': [
|
||||
{'name': 'Pomax'},
|
||||
{'name': 'Alan'}
|
||||
]
|
||||
}
|
||||
postresponse = self.client.post(
|
||||
'/api/pulse/entries/',
|
||||
|
@ -201,33 +242,26 @@ class TestEntryView(PulseStaffTestCase):
|
|||
)
|
||||
self.assertEqual(tagList, ['test1', 'test2', 'test3'])
|
||||
|
||||
def test_post_entry_with_mixed_creators(self):
|
||||
def test_post_entry_with_mixed_creators_v1(self):
|
||||
"""
|
||||
Post entry with some existing creators, some new creators
|
||||
Make sure that they are in the db.
|
||||
"""
|
||||
|
||||
creators = ['Pomax', 'Alan']
|
||||
payload = {
|
||||
'title': 'title test_post_entry_with_mixed_creators',
|
||||
'description': 'description test_post_entry_with_mixed_creators',
|
||||
'creators': creators,
|
||||
}
|
||||
json.loads(
|
||||
str(self.client.get('/api/pulse/nonce/').content, 'utf-8')
|
||||
)
|
||||
self.client.post(
|
||||
'/api/pulse/entries/',
|
||||
data=self.generatePostPayload(data=payload)
|
||||
run_test_entry_creators(
|
||||
test_case=self,
|
||||
creator_id_key='creator_id'
|
||||
)
|
||||
|
||||
query_filter = Q(name=creators[0])
|
||||
for creator in creators:
|
||||
query_filter = query_filter | Q(name=creator)
|
||||
|
||||
creator_list_count = Creator.objects.filter(query_filter).count()
|
||||
|
||||
self.assertEqual(len(creators), creator_list_count)
|
||||
def test_post_entry_with_mixed_creators_v2(self):
|
||||
"""
|
||||
Post entry with some existing creators, some new creators
|
||||
Make sure that they are in the db.
|
||||
"""
|
||||
run_test_entry_creators(
|
||||
test_case=self,
|
||||
api_version=settings.API_VERSIONS['version_2'],
|
||||
creator_id_key='profile_id'
|
||||
)
|
||||
|
||||
def test_post_entry_as_creator(self):
|
||||
"""
|
||||
|
@ -239,23 +273,57 @@ class TestEntryView(PulseStaffTestCase):
|
|||
'description': 'description test_post_entry_as_creator',
|
||||
'published_by_creator': True,
|
||||
}
|
||||
postresponse = self.client.post(
|
||||
'/api/pulse/entries/',
|
||||
response = self.client.post(
|
||||
reverse('entries-list'),
|
||||
data=self.generatePostPayload(data=payload)
|
||||
)
|
||||
self.assertEqual(postresponse.status_code, 200)
|
||||
responseobj = json.loads(str(postresponse.content, 'utf-8'))
|
||||
id = str(responseobj['id'])
|
||||
self.assertEqual(response.status_code, 200)
|
||||
entry_id = int(json.loads(str(response.content, 'utf-8'))['id'])
|
||||
entry_creator = Entry.objects.get(id=entry_id).related_entry_creators.last()
|
||||
|
||||
entry_from_REST = self.client.get('/api/pulse/entries/' + id, follow=True)
|
||||
data = json.loads(str(entry_from_REST.content, 'utf-8'))
|
||||
self.assertEquals(self.user.name, data['related_creators'][0]['name'])
|
||||
self.assertEqual(entry_creator.profile.id, self.user.profile.id)
|
||||
|
||||
def test_get_entries_list(self):
|
||||
def run_test_get_entry_list(self, entries_url, serializer_class):
|
||||
"""Get /entries endpoint"""
|
||||
entries = Entry.objects.public().with_related()
|
||||
page_size = EntriesPagination().get_page_size(
|
||||
request=Request(request=HttpRequest())
|
||||
) # mock request to satisfy the required arguments)
|
||||
|
||||
entryList = self.client.get('/api/pulse/entries/')
|
||||
self.assertEqual(entryList.status_code, 200)
|
||||
for page_number in range(ceil(len(entries) / page_size)):
|
||||
start_entry_index = page_number * page_size
|
||||
end_entry_index = start_entry_index + page_size
|
||||
entry_list = serializer_class(
|
||||
entries[start_entry_index:end_entry_index],
|
||||
many=True,
|
||||
).data
|
||||
response = self.client.get('{url}?page={page_number}'.format(
|
||||
url=entries_url,
|
||||
page_number=page_number + 1,
|
||||
))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
response_entries = json.loads(str(response.content, 'utf-8'))['results']
|
||||
self.assertListEqual(response_entries, entry_list)
|
||||
|
||||
def test_get_entries_v1_list(self):
|
||||
"""Get /v1/entries endpoint"""
|
||||
self.run_test_get_entry_list(
|
||||
entries_url=reverse(
|
||||
'entries-list',
|
||||
args=[settings.API_VERSIONS['version_1'] + '/']
|
||||
),
|
||||
serializer_class=EntrySerializerWithV1Creators
|
||||
)
|
||||
|
||||
def test_get_entries_v2_list(self):
|
||||
"""Get /v2/entries endpoint"""
|
||||
self.run_test_get_entry_list(
|
||||
entries_url=reverse(
|
||||
'entries-list',
|
||||
args=[settings.API_VERSIONS['version_2'] + '/']
|
||||
),
|
||||
serializer_class=EntrySerializerWithCreators
|
||||
)
|
||||
|
||||
def test_entries_search(self):
|
||||
"""Make sure filtering searches works"""
|
||||
|
@ -614,7 +682,7 @@ class TestEntryView(PulseStaffTestCase):
|
|||
entry = Entry.objects.get(id=entry_id)
|
||||
self.assertEqual(entry.moderation_state, state)
|
||||
|
||||
def test_entry_serializer(self):
|
||||
def run_test_entry_serializer_with_creators(self, serializer_class):
|
||||
"""
|
||||
Make sure that the entry serializer contains all the custom data needed.
|
||||
Useful test to make sure our custom fields are tested and not
|
||||
|
@ -622,7 +690,7 @@ class TestEntryView(PulseStaffTestCase):
|
|||
"""
|
||||
|
||||
entries = self.entries
|
||||
serialized_entries = EntrySerializer(entries, many=True).data
|
||||
serialized_entries = serializer_class(entries, many=True).data
|
||||
|
||||
for i in range(len(serialized_entries)):
|
||||
entry = entries[i]
|
||||
|
@ -631,33 +699,14 @@ class TestEntryView(PulseStaffTestCase):
|
|||
related_creators = serialized_entry['related_creators']
|
||||
# Make sure that the number of serialized creators matches the
|
||||
# number of creators for that entry in the db
|
||||
db_entry_creator_count = OrderedCreatorRecord.objects.filter(entry=entry).count()
|
||||
db_entry_creator_count = EntryCreator.objects.filter(entry=entry).count()
|
||||
self.assertEqual(len(related_creators), db_entry_creator_count)
|
||||
|
||||
def test_post_entry_related_creators(self):
|
||||
"""
|
||||
Make sure that we can post related creators with an entry
|
||||
"""
|
||||
creator1_id = self.creators[0].id
|
||||
creator2_name = 'Bob'
|
||||
payload = self.generatePostPayload(data={
|
||||
'title': 'title test_entries_issue',
|
||||
'description': 'description test_entries_issue',
|
||||
'related_creators': [{
|
||||
'creator_id': creator1_id
|
||||
}, {
|
||||
'name': creator2_name
|
||||
}]
|
||||
})
|
||||
def test_entry_serializer_with_creators(self):
|
||||
self.run_test_entry_serializer_with_creators(EntrySerializerWithCreators)
|
||||
|
||||
response = self.client.post('/api/pulse/entries/', payload)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
content = json.loads(str(response.content, 'utf-8'))
|
||||
entry_id = int(content['id'])
|
||||
related_creators = OrderedCreatorRecord.objects.filter(entry__id=entry_id)
|
||||
self.assertEqual(len(related_creators), 2)
|
||||
self.assertEqual(related_creators[0].creator.id, creator1_id)
|
||||
self.assertEqual(related_creators[1].creator.name, creator2_name)
|
||||
def test_entry_serializer_with_v1_creators(self):
|
||||
self.run_test_entry_serializer_with_creators(EntrySerializerWithV1Creators)
|
||||
|
||||
def test_post_entry_published_by_creator_dupe_related_creator(self):
|
||||
"""
|
||||
|
@ -665,7 +714,7 @@ class TestEntryView(PulseStaffTestCase):
|
|||
while posting an entry and also provide the same creator in the
|
||||
"related_creators" property, we only add it to the db once.
|
||||
"""
|
||||
creator = self.user.profile.related_creator
|
||||
creator = self.user.profile
|
||||
payload = self.generatePostPayload(data={
|
||||
'title': 'title test_post_entry_published_by_creator_dupe_related_creator',
|
||||
'description': 'description test_post_entry_published_by_creator_dupe_related_creator',
|
||||
|
@ -675,10 +724,10 @@ class TestEntryView(PulseStaffTestCase):
|
|||
'published_by_creator': True
|
||||
})
|
||||
|
||||
response = self.client.post('/api/pulse/entries/', payload)
|
||||
response = self.client.post(reverse('entries-list'), payload)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
content = json.loads(str(response.content, 'utf-8'))
|
||||
entry_id = int(content['id'])
|
||||
related_creators = OrderedCreatorRecord.objects.filter(entry__id=entry_id)
|
||||
related_creators = EntryCreator.objects.filter(entry__id=entry_id)
|
||||
self.assertEqual(len(related_creators), 1)
|
||||
self.assertEqual(related_creators[0].creator.id, creator.id)
|
||||
self.assertEqual(related_creators[0].profile.id, creator.id)
|
||||
|
|
|
@ -5,6 +5,8 @@ import base64
|
|||
import django_filters
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
|
||||
from rest_framework import filters, status
|
||||
from rest_framework.decorators import detail_route, api_view
|
||||
|
@ -13,9 +15,12 @@ from rest_framework.pagination import PageNumberPagination
|
|||
from rest_framework.response import Response
|
||||
from rest_framework.parsers import JSONParser
|
||||
|
||||
from pulseapi.creators.models import Creator, OrderedCreatorRecord
|
||||
from pulseapi.entries.models import Entry, ModerationState
|
||||
from pulseapi.entries.serializers import EntrySerializer, ModerationStateSerializer
|
||||
from pulseapi.entries.serializers import (
|
||||
EntrySerializerWithV1Creators,
|
||||
EntrySerializerWithCreators,
|
||||
ModerationStateSerializer,
|
||||
)
|
||||
from pulseapi.profiles.models import UserBookmarks
|
||||
|
||||
from pulseapi.utility.userpermissions import is_staff_address
|
||||
|
@ -207,16 +212,22 @@ class EntryView(RetrieveAPIView):
|
|||
"""
|
||||
|
||||
queryset = Entry.objects.public().with_related()
|
||||
serializer_class = EntrySerializer
|
||||
pagination_class = None
|
||||
parser_classes = (
|
||||
JSONParser,
|
||||
)
|
||||
|
||||
def get_serializer_class(self):
|
||||
request = self.request
|
||||
|
||||
if request and request.version == settings.API_VERSIONS['version_1']:
|
||||
return EntrySerializerWithV1Creators
|
||||
|
||||
return EntrySerializerWithCreators
|
||||
|
||||
|
||||
class BookmarkedEntries(ListAPIView):
|
||||
pagination_class = EntriesPagination
|
||||
serializer_class = EntrySerializer
|
||||
parser_classes = (
|
||||
JSONParser,
|
||||
)
|
||||
|
@ -230,6 +241,14 @@ class BookmarkedEntries(ListAPIView):
|
|||
bookmarks = UserBookmarks.objects.filter(profile=user.profile)
|
||||
return Entry.objects.filter(bookmarked_by__in=bookmarks).order_by('-bookmarked_by__timestamp')
|
||||
|
||||
def get_serializer_class(self):
|
||||
request = self.request
|
||||
|
||||
if request and request.version == settings.API_VERSIONS['version_1']:
|
||||
return EntrySerializerWithV1Creators
|
||||
|
||||
return EntrySerializerWithCreators
|
||||
|
||||
# When people POST to this route, we want to do some
|
||||
# custom validation involving CSRF and nonce validation,
|
||||
# so we intercept the POST handling a little.
|
||||
|
@ -245,10 +264,6 @@ class BookmarkedEntries(ListAPIView):
|
|||
user = request.user
|
||||
ids = self.request.query_params.get('ids', None)
|
||||
|
||||
# This var was set, but never used. Commented off
|
||||
# rather than deleted just in case:
|
||||
# queryset = Entry.objects.public().with_related()
|
||||
|
||||
def bookmark_entry(id):
|
||||
entry = None
|
||||
|
||||
|
@ -330,7 +345,6 @@ class EntriesListView(ListCreateAPIView):
|
|||
'interest',
|
||||
'tags__name',
|
||||
)
|
||||
serializer_class = EntrySerializer
|
||||
parser_classes = (
|
||||
JSONParser,
|
||||
)
|
||||
|
@ -394,17 +408,27 @@ class EntriesListView(ListCreateAPIView):
|
|||
# https://docs.python.org/3/tutorial/classes.html#private-variables-and-class-local-references
|
||||
|
||||
queryset = queryset.filter(
|
||||
related_creators__creator__name__in=creator_names
|
||||
Q(related_entry_creators__profile__custom_name__in=creator_names) |
|
||||
Q(related_entry_creators__profile__related_user__name__in=creator_names)
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self):
|
||||
request = self.request
|
||||
|
||||
if request and request.version == settings.API_VERSIONS['version_1']:
|
||||
return EntrySerializerWithV1Creators
|
||||
|
||||
return EntrySerializerWithCreators
|
||||
|
||||
# When people POST to this route, we want to do some
|
||||
# custom validation involving CSRF and nonce validation,
|
||||
# so we intercept the POST handling a little.
|
||||
@detail_route(methods=['post'])
|
||||
def post(self, request, *args, **kwargs):
|
||||
request_data = request.data
|
||||
user = request.user if hasattr(request, 'user') else None
|
||||
|
||||
validation_result = post_validate(request)
|
||||
|
||||
|
@ -434,10 +458,6 @@ class EntriesListView(ListCreateAPIView):
|
|||
except:
|
||||
pass
|
||||
|
||||
# we need to split out creators, because it's a many-to-many
|
||||
# relation with a Through class, so that needs manual labour:
|
||||
creator_data = request_data.pop('creators', [])
|
||||
|
||||
# we also want to make sure that tags are properly split
|
||||
# on commas, in case we get e.g. ['a', 'b' 'c,d']
|
||||
if 'tags' in request_data:
|
||||
|
@ -450,12 +470,11 @@ class EntriesListView(ListCreateAPIView):
|
|||
filtered_tags.append(tag)
|
||||
request_data['tags'] = filtered_tags
|
||||
|
||||
serializer = EntrySerializer(
|
||||
serializer = self.get_serializer_class()(
|
||||
data=request_data,
|
||||
context={'request': request},
|
||||
context={'user': user},
|
||||
)
|
||||
if serializer.is_valid():
|
||||
user = request.user
|
||||
# ensure that the published_by is always the user doing
|
||||
# the posting, and set 'featured' to false.
|
||||
#
|
||||
|
@ -464,7 +483,7 @@ class EntriesListView(ListCreateAPIView):
|
|||
name='Pending'
|
||||
)
|
||||
|
||||
if (is_staff_address(request.user.email)):
|
||||
if (is_staff_address(user.email)):
|
||||
moderation_state = ModerationState.objects.get(
|
||||
name='Approved'
|
||||
)
|
||||
|
@ -476,17 +495,6 @@ class EntriesListView(ListCreateAPIView):
|
|||
moderation_state=moderation_state
|
||||
)
|
||||
|
||||
# QUEUED FOR DEPRECATION: Use the `related_creators` property instead.
|
||||
# See https://github.com/mozilla/network-pulse-api/issues/241
|
||||
if len(creator_data) > 0:
|
||||
for creator_name in creator_data:
|
||||
(creator, _) = Creator.objects.get_or_create(name=creator_name)
|
||||
|
||||
OrderedCreatorRecord.objects.create(
|
||||
entry=saved_entry,
|
||||
creator=creator
|
||||
)
|
||||
|
||||
return Response({'status': 'submitted', 'id': saved_entry.id})
|
||||
else:
|
||||
return Response(
|
||||
|
|
|
@ -2,7 +2,6 @@ from django.contrib import admin
|
|||
from django.utils.html import format_html
|
||||
|
||||
from pulseapi.utility.get_admin_url import get_admin_url
|
||||
from pulseapi.profiles.forms import UserProfileAdminForm
|
||||
from .models import (
|
||||
ProfileType,
|
||||
ProgramType,
|
||||
|
@ -16,8 +15,6 @@ class UserProfileAdmin(admin.ModelAdmin):
|
|||
"""
|
||||
Show the profile-associated user.
|
||||
"""
|
||||
form = UserProfileAdminForm
|
||||
|
||||
fields = (
|
||||
'is_active',
|
||||
'user_account',
|
||||
|
@ -40,7 +37,6 @@ class UserProfileAdmin(admin.ModelAdmin):
|
|||
'program_year',
|
||||
'affiliation',
|
||||
'user_bio_long',
|
||||
'creator',
|
||||
)
|
||||
|
||||
readonly_fields = (
|
||||
|
|
|
@ -5,6 +5,3 @@ from django.utils.translation import ugettext_lazy
|
|||
class ProfilesConfig(AppConfig):
|
||||
name = 'pulseapi.profiles'
|
||||
verbose_name = ugettext_lazy('user profiles')
|
||||
|
||||
def ready(self):
|
||||
import pulseapi.profiles.signals # noqa
|
||||
|
|
|
@ -1,76 +0,0 @@
|
|||
from django import forms
|
||||
|
||||
from pulseapi.creators.models import Creator
|
||||
|
||||
|
||||
class UserProfileAdminForm(forms.ModelForm):
|
||||
"""
|
||||
We use this form for the admin view of a profile so that
|
||||
we can add new read-write fields that we want in the admin view
|
||||
that aren't present in the model, e.g. reverse-relation fields.
|
||||
Any model field that needs to show up in the admin should be defined
|
||||
in the fields for the UserProfileAdmin. Only additional non-model
|
||||
fields or fields that require custom logic should be defined in this form.
|
||||
"""
|
||||
creator = forms.ModelChoiceField(
|
||||
required=False,
|
||||
empty_label='(Create one for me)',
|
||||
queryset=Creator.objects.all(),
|
||||
help_text='The creator associated with this profile.<br />'
|
||||
'NOTE: If you set this to a creator that is already associated with another profile, '
|
||||
'the creator will no longer be attached to that profile and will '
|
||||
'instead be associated with the current profile instead.',
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
instance = kwargs.get('instance', None)
|
||||
|
||||
if instance:
|
||||
# We are updating a profile vs. creating a new one
|
||||
self.create = False
|
||||
if hasattr(instance, 'related_creator'):
|
||||
kwargs['initial'] = {'creator': instance.related_creator}
|
||||
else:
|
||||
self.create = True
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def save(self, commit=True):
|
||||
'''
|
||||
Current hack for making sure that we can choose a creator for a profile
|
||||
via the admin. Unfortunately, we cannot circumvent the post_save hook
|
||||
that automatically creates a Creator for a new profile, so instead,
|
||||
we pass along the selected creator to the post_save so that it knows to
|
||||
use the selected creator instead of creating a new Creator.
|
||||
For updating profiles, the post_save logic isn't triggered and hence,
|
||||
we handle the binding of the profile to the selected Creator here, but
|
||||
we have to wait for the profile to be saved first to associate it with
|
||||
the Creator.
|
||||
'''
|
||||
instance = super().save(commit=False)
|
||||
# Get the chosen creator
|
||||
creator = self.cleaned_data['creator']
|
||||
|
||||
# If this is a new profile and a creator was chosen, pass it along
|
||||
# to the post_save hook.
|
||||
if creator is not None and self.create:
|
||||
instance._creator = creator
|
||||
|
||||
instance.save()
|
||||
|
||||
# Handle updating a profile
|
||||
if not self.create:
|
||||
# Make sure that a different existing creator was set
|
||||
if creator is not None and creator.profile != instance:
|
||||
# First unbind the creator already related to this profile
|
||||
# We use a safe approach here to simply unbind vs. delete the creator
|
||||
# So that entries that refer to that creator don't break.
|
||||
Creator.objects.filter(pk=instance.related_creator.pk).update(profile=None, name=instance.name)
|
||||
# Rebind to the new creator
|
||||
creator.profile = instance
|
||||
creator.save()
|
||||
# Create a new Creator if that was what was chosen
|
||||
if creator is None:
|
||||
creator = Creator.objects.get_or_create(profile=instance)
|
||||
|
||||
return instance
|
|
@ -0,0 +1,44 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2018-03-15 21:17
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def create_profiles_for_creators_without_profiles(apps, schema_editor):
|
||||
UserProfile = apps.get_model('profiles', 'UserProfile')
|
||||
Creator = apps.get_model('creators', 'Creator')
|
||||
for creator in Creator.objects.filter(profile__isnull=True):
|
||||
profile = UserProfile.objects.create(custom_name=creator.name)
|
||||
creator.profile = profile
|
||||
creator.save()
|
||||
|
||||
|
||||
def create_entrycreators_for_orderedcreatorrecords(apps, schema_editor):
|
||||
OrderedCreatorRecord = apps.get_model('creators', 'OrderedCreatorRecord')
|
||||
EntryCreator = apps.get_model('creators', 'EntryCreator')
|
||||
for ocr in OrderedCreatorRecord.objects.all():
|
||||
EntryCreator.objects.create(
|
||||
entry=ocr.entry,
|
||||
profile=ocr.creator.profile
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('profiles', '0017_auto_20180213_1202'),
|
||||
('creators', '0013_auto_20180410_2055')
|
||||
]
|
||||
|
||||
operations = [
|
||||
# First increase the size of the name field in profiles to accommodate
|
||||
# creator's names which can be 140 characters long
|
||||
migrations.AlterField(
|
||||
model_name='userprofile',
|
||||
name='custom_name',
|
||||
field=models.CharField(blank=True, max_length=140),
|
||||
),
|
||||
migrations.RunPython(create_profiles_for_creators_without_profiles),
|
||||
migrations.RunPython(create_entrycreators_for_orderedcreatorrecords)
|
||||
]
|
|
@ -73,6 +73,18 @@ class ProgramYear(models.Model):
|
|||
return self.value
|
||||
|
||||
|
||||
class UserProfileQuerySet(models.query.QuerySet):
|
||||
"""
|
||||
A queryset for profiles with convenience queries
|
||||
"""
|
||||
|
||||
def active(self):
|
||||
"""
|
||||
Return all profiles that have the is_active flag set to True
|
||||
"""
|
||||
return self.filter(is_active=True)
|
||||
|
||||
|
||||
class UserProfile(models.Model):
|
||||
"""
|
||||
This class houses all user profile information,
|
||||
|
@ -103,7 +115,7 @@ class UserProfile(models.Model):
|
|||
#
|
||||
# Examples of this are nicknames, pseudonyms, and org names
|
||||
custom_name = models.CharField(
|
||||
max_length=70,
|
||||
max_length=140,
|
||||
blank=True
|
||||
)
|
||||
|
||||
|
@ -262,6 +274,8 @@ class UserProfile(models.Model):
|
|||
blank=True
|
||||
)
|
||||
|
||||
objects = UserProfileQuerySet.as_manager()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.profile_type is None:
|
||||
self.profile_type = ProfileType.get_default_profile_type()
|
||||
|
|
|
@ -6,10 +6,12 @@ from pulseapi.profiles.models import (
|
|||
UserProfile,
|
||||
UserBookmarks,
|
||||
)
|
||||
from pulseapi.creators.models import OrderedCreatorRecord
|
||||
from pulseapi.creators.serializers import EntryOrderedCreatorSerializer
|
||||
from pulseapi.creators.models import EntryCreator
|
||||
from pulseapi.entries.models import Entry
|
||||
from pulseapi.entries.serializers import EntryBaseSerializer, EntrySerializer
|
||||
from pulseapi.entries.serializers import (
|
||||
EntryWithCreatorsBaseSerializer,
|
||||
EntrySerializerWithV1Creators,
|
||||
)
|
||||
|
||||
|
||||
# Helper function to remove a value from a dictionary
|
||||
|
@ -138,6 +140,18 @@ class UserProfileSerializer(serializers.ModelSerializer):
|
|||
]
|
||||
|
||||
|
||||
class UserProfileBasicSerializer(serializers.BaseSerializer):
|
||||
"""
|
||||
A read-only serializer that serializes a user profile by only including indentity
|
||||
information like `id` and `name`.
|
||||
"""
|
||||
def to_representation(self, obj):
|
||||
return {
|
||||
'id': obj.id,
|
||||
'name': obj.name
|
||||
}
|
||||
|
||||
|
||||
class UserProfilePublicSerializer(UserProfileSerializer):
|
||||
"""
|
||||
Serializes a user profile for public view
|
||||
|
@ -147,7 +161,7 @@ class UserProfilePublicSerializer(UserProfileSerializer):
|
|||
|
||||
def get_my_profile(self, instance):
|
||||
request = self.context.get('request')
|
||||
return request.user == instance.user if request else None
|
||||
return request.user == instance.user if request else False
|
||||
|
||||
|
||||
class UserProfilePublicWithEntriesSerializer(UserProfilePublicSerializer):
|
||||
|
@ -160,17 +174,16 @@ class UserProfilePublicWithEntriesSerializer(UserProfilePublicSerializer):
|
|||
def get_published_entries(self, instance):
|
||||
user = instance.user
|
||||
|
||||
return EntrySerializer(
|
||||
return EntrySerializerWithV1Creators(
|
||||
user.entries.public().with_related().order_by('-id'),
|
||||
context=self.context,
|
||||
many=True,
|
||||
).data if user else []
|
||||
|
||||
created_entries = serializers.SerializerMethodField()
|
||||
|
||||
def get_created_entries(self, instance):
|
||||
entry_creator_records = OrderedCreatorRecord.objects.filter(creator__profile=instance).order_by('-id')
|
||||
return [EntrySerializer(x.entry).data for x in entry_creator_records if x.entry.is_approved()]
|
||||
entry_creators = EntryCreator.objects.filter(profile=instance).order_by('-id')
|
||||
return [EntrySerializerWithV1Creators(x.entry).data for x in entry_creators if x.entry.is_approved()]
|
||||
|
||||
|
||||
class UserProfileEntriesSerializer(serializers.Serializer):
|
||||
|
@ -188,14 +201,6 @@ class UserProfileEntriesSerializer(serializers.Serializer):
|
|||
returning the number of entries (created, published, and favorited) associated
|
||||
with the profile.
|
||||
"""
|
||||
@staticmethod
|
||||
def serialize_entry(entry):
|
||||
serialized_entry = EntryBaseSerializer(entry).data
|
||||
serialized_entry['related_creators'] = EntryOrderedCreatorSerializer(
|
||||
entry.related_creators,
|
||||
many=True
|
||||
).data
|
||||
return serialized_entry
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = {}
|
||||
|
@ -203,12 +208,13 @@ class UserProfileEntriesSerializer(serializers.Serializer):
|
|||
include_created = context.get('created', False)
|
||||
include_published = context.get('published', False)
|
||||
include_favorited = context.get('favorited', False)
|
||||
EntrySerializerClass = context.get('EntrySerializerClass', EntryWithCreatorsBaseSerializer)
|
||||
|
||||
# If none of the filter options are provided, only return the count of
|
||||
# entries associated with this profile
|
||||
if not (include_created or include_published or include_favorited):
|
||||
entry_count = Entry.objects.public().filter(
|
||||
Q(related_creators__creator__profile=instance) |
|
||||
Q(related_entry_creators__profile=instance) |
|
||||
Q(published_by=instance.user) |
|
||||
Q(bookmarked_by__profile=instance)
|
||||
).distinct().count()
|
||||
|
@ -217,29 +223,29 @@ class UserProfileEntriesSerializer(serializers.Serializer):
|
|||
}
|
||||
|
||||
entry_queryset = Entry.objects.prefetch_related(
|
||||
'related_creators__creator__profile__related_user'
|
||||
'related_entry_creators__profile__related_user'
|
||||
)
|
||||
|
||||
if include_created:
|
||||
ordered_creators = (
|
||||
OrderedCreatorRecord.objects
|
||||
entry_creators = (
|
||||
EntryCreator.objects
|
||||
.prefetch_related(Prefetch('entry', queryset=entry_queryset))
|
||||
.filter(creator=instance.related_creator)
|
||||
.filter(profile=instance)
|
||||
.filter(entry__in=Entry.objects.public())
|
||||
)
|
||||
data['created'] = [
|
||||
self.serialize_entry(ordered_creator.entry)
|
||||
for ordered_creator in ordered_creators
|
||||
EntrySerializerClass(entry_creator.entry).data
|
||||
for entry_creator in entry_creators
|
||||
]
|
||||
|
||||
if include_published:
|
||||
entries = entry_queryset.public().filter(published_by=instance.user) if instance.user else []
|
||||
data['published'] = [self.serialize_entry(entry) for entry in entries]
|
||||
data['published'] = EntrySerializerClass(entries, many=True).data
|
||||
|
||||
if include_favorited:
|
||||
user_bookmarks = UserBookmarks.objects.filter(profile=instance)
|
||||
data['favorited'] = [
|
||||
self.serialize_entry(bookmark.entry) for bookmark in
|
||||
EntrySerializerClass(bookmark.entry).data for bookmark in
|
||||
user_bookmarks.prefetch_related(
|
||||
Prefetch('entry', queryset=entry_queryset)
|
||||
).filter(entry__in=Entry.objects.public())
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
from django.dispatch import receiver
|
||||
from django.db.models.signals import post_save
|
||||
|
||||
from pulseapi.profiles.models import UserProfile
|
||||
from pulseapi.creators.models import Creator
|
||||
|
||||
|
||||
@receiver(post_save, sender=UserProfile)
|
||||
def create_creator_for_profile(sender, **kwargs):
|
||||
"""
|
||||
Automatically create a corresponding Creator instance for every profile that is created.
|
||||
"""
|
||||
instance = kwargs.get('instance')
|
||||
# This will check to see if a creator was passed in and use that
|
||||
# to bind to the current profile instead of creating a new one
|
||||
creator = instance._creator if hasattr(instance, '_creator') else None
|
||||
|
||||
if kwargs.get('created', False) and not kwargs.get('raw', False):
|
||||
if creator is not None:
|
||||
creator.profile = instance
|
||||
creator.save()
|
||||
else:
|
||||
Creator.objects.get_or_create(profile=kwargs.get('instance'))
|
|
@ -1,20 +1,28 @@
|
|||
import json
|
||||
|
||||
from urllib.parse import urlencode
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
|
||||
from .models import UserProfile, ProfileType, ProgramType, ProgramYear
|
||||
|
||||
from pulseapi.tests import PulseMemberTestCase
|
||||
from pulseapi.entries.models import Entry
|
||||
from pulseapi.entries.serializers import EntrySerializer
|
||||
from pulseapi.creators.models import OrderedCreatorRecord
|
||||
from pulseapi.entries.models import Entry, ModerationState
|
||||
from pulseapi.entries.serializers import (
|
||||
EntrySerializerWithV1Creators,
|
||||
EntryWithCreatorsBaseSerializer,
|
||||
EntryWithV1CreatorsBaseSerializer,
|
||||
)
|
||||
from pulseapi.entries.factory import BasicEntryFactory
|
||||
from pulseapi.users.factory import BasicEmailUserFactory
|
||||
from pulseapi.creators.models import EntryCreator
|
||||
from pulseapi.creators.factory import EntryCreatorFactory
|
||||
from pulseapi.profiles.serializers import (
|
||||
UserProfileEntriesSerializer,
|
||||
UserProfilePublicSerializer,
|
||||
UserProfilePublicWithEntriesSerializer,
|
||||
UserProfileBasicSerializer,
|
||||
)
|
||||
from pulseapi.profiles.factory import UserBookmarksFactory, ExtendedUserProfileFactory
|
||||
|
||||
|
||||
class TestProfileView(PulseMemberTestCase):
|
||||
|
@ -22,43 +30,41 @@ class TestProfileView(PulseMemberTestCase):
|
|||
"""
|
||||
Check if we can get a single profile by its `id`
|
||||
"""
|
||||
id = self.users_with_profiles[0].id
|
||||
id = self.users_with_profiles[0].profile.id
|
||||
response = self.client.get(reverse('profile', kwargs={'pk': id}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_profile_data_serialization(self):
|
||||
def test_v1_profile_data_serialization(self):
|
||||
"""
|
||||
Make sure profiles have "created_entries" array
|
||||
Make sure v1 profiles have "created_entries" array
|
||||
"""
|
||||
user = self.users_with_profiles[0]
|
||||
profile = user.profile
|
||||
location = "Springfield, IL"
|
||||
profile.location = location
|
||||
profile.save()
|
||||
|
||||
location = 'Springfield, IL'
|
||||
profile = ExtendedUserProfileFactory(location=location)
|
||||
id = profile.id
|
||||
response = self.client.get(reverse('profile', kwargs={'pk': id}))
|
||||
entriesjson = json.loads(str(response.content, 'utf-8'))
|
||||
|
||||
self.assertEqual(entriesjson['location'], location)
|
||||
response = self.client.get(reverse('profile', args=[
|
||||
settings.API_VERSIONS['version_1'] + '/',
|
||||
id
|
||||
]))
|
||||
response_entries = json.loads(str(response.content, 'utf-8'))
|
||||
|
||||
self.assertEqual(response_entries['location'], location)
|
||||
|
||||
created_entries = []
|
||||
entry_creators = OrderedCreatorRecord.objects.filter(
|
||||
creator__profile=id
|
||||
).order_by('-id')
|
||||
entry_creators = EntryCreator.objects.filter(profile=profile).order_by('-id')
|
||||
|
||||
created_entries = [EntrySerializer(x.entry).data for x in entry_creators]
|
||||
self.assertEqual(entriesjson['profile_id'], id)
|
||||
self.assertEqual(entriesjson['created_entries'], created_entries)
|
||||
created_entries = [EntrySerializerWithV1Creators(x.entry).data for x in entry_creators]
|
||||
self.assertEqual(response_entries['profile_id'], id)
|
||||
self.assertEqual(response_entries['created_entries'], created_entries)
|
||||
|
||||
# make sure extended profile data does not show
|
||||
self.assertEqual('program_type' in entriesjson, False)
|
||||
self.assertIn('program_type', response_entries)
|
||||
|
||||
def test_v2_profile_data_serialization(self):
|
||||
"""
|
||||
Make sure profiles do not have "created_entries" array for API version 2
|
||||
"""
|
||||
profile = OrderedCreatorRecord.objects.filter(creator__profile__isnull=False)[:1].get().creator.profile
|
||||
profile = EntryCreator.objects.first().profile
|
||||
response = self.client.get(reverse('profile', args=[
|
||||
settings.API_VERSIONS['version_2'] + '/',
|
||||
profile.id
|
||||
|
@ -68,86 +74,128 @@ class TestProfileView(PulseMemberTestCase):
|
|||
self.assertEqual(profile_json['profile_id'], profile.id)
|
||||
self.assertNotIn('created_entries', profile_json)
|
||||
|
||||
def test_profile_entries_created(self):
|
||||
"""
|
||||
Get the created entries for a profile
|
||||
"""
|
||||
profile = OrderedCreatorRecord.objects.filter(creator__profile__isnull=False)[:1].get().creator.profile
|
||||
entries = [
|
||||
UserProfileEntriesSerializer.serialize_entry(ocr.entry)
|
||||
for ocr in OrderedCreatorRecord.objects.filter(creator__profile=profile)
|
||||
]
|
||||
def run_test_profile_entries(self, version, entry_type):
|
||||
entry_serializer_class = EntryWithCreatorsBaseSerializer
|
||||
if version == settings.API_VERSIONS['version_1']:
|
||||
entry_serializer_class = EntryWithV1CreatorsBaseSerializer
|
||||
|
||||
user = self.user
|
||||
# "Created" entry_type profile used as default
|
||||
profile = EntryCreator.objects.first().profile
|
||||
|
||||
if entry_type is 'published':
|
||||
profile = user.profile
|
||||
approved = ModerationState.objects.get(name='Approved')
|
||||
for _ in range(0, 3):
|
||||
BasicEntryFactory.create(published_by=user, moderation_state=approved)
|
||||
elif entry_type is 'favorited':
|
||||
profile = user.profile
|
||||
for entry in Entry.objects.all()[:4]:
|
||||
self.client.put(reverse('bookmark', kwargs={'entryid': entry.id}))
|
||||
|
||||
expected_entries = UserProfileEntriesSerializer(
|
||||
instance=profile,
|
||||
context={
|
||||
'created': entry_type is 'created',
|
||||
'published': entry_type is 'published',
|
||||
'favorited': entry_type is 'favorited',
|
||||
'EntrySerializerClass': entry_serializer_class
|
||||
},
|
||||
).data[entry_type]
|
||||
|
||||
response = self.client.get(
|
||||
'{url}?created'.format(
|
||||
url=reverse('profile-entries', kwargs={'pk': profile.id})
|
||||
'{url}?{entry_type}'.format(
|
||||
url=reverse(
|
||||
'profile-entries',
|
||||
args=[version + '/', profile.id]
|
||||
),
|
||||
entry_type=entry_type
|
||||
)
|
||||
)
|
||||
profile_json = json.loads(str(response.content, 'utf-8'))
|
||||
self.assertListEqual(profile_json['created'], entries)
|
||||
self.assertListEqual(profile_json[entry_type], expected_entries)
|
||||
|
||||
def test_profile_entries_published(self):
|
||||
def test_profile_v1_entries_created(self):
|
||||
"""
|
||||
Get the published entries for a profile
|
||||
Get the created entries for a profile (v1)
|
||||
"""
|
||||
profile = UserProfile.objects.filter(related_user__entries__isnull=False)[:1].get()
|
||||
entries = [
|
||||
UserProfileEntriesSerializer.serialize_entry(entry)
|
||||
for entry in Entry.objects.public().filter(published_by=profile.user)
|
||||
]
|
||||
|
||||
response = self.client.get(
|
||||
'{url}?published'.format(
|
||||
url=reverse('profile-entries', kwargs={'pk': profile.id})
|
||||
)
|
||||
self.run_test_profile_entries(
|
||||
version=settings.API_VERSIONS['version_1'],
|
||||
entry_type='created',
|
||||
)
|
||||
profile_json = json.loads(str(response.content, 'utf-8'))
|
||||
self.assertListEqual(profile_json['published'], entries)
|
||||
|
||||
def test_profile_entries_favorited(self):
|
||||
def test_profile_v2_entries_created(self):
|
||||
"""
|
||||
Get the favorited entries for a profile
|
||||
Get the created entries for a profile (v2)
|
||||
"""
|
||||
profile = self.user.profile
|
||||
entries = Entry.objects.public()[:2]
|
||||
serialized_entries = []
|
||||
|
||||
for entry in entries:
|
||||
self.client.put(reverse('bookmark', kwargs={'entryid': entry.id}))
|
||||
serialized_entries.append(UserProfileEntriesSerializer.serialize_entry(entry))
|
||||
|
||||
response = self.client.get(
|
||||
'{url}?favorited'.format(
|
||||
url=reverse('profile-entries', kwargs={'pk': profile.id})
|
||||
)
|
||||
self.run_test_profile_entries(
|
||||
version=settings.API_VERSIONS['version_2'],
|
||||
entry_type='created',
|
||||
)
|
||||
|
||||
def test_profile_v1_entries_published(self):
|
||||
"""
|
||||
Get the published entries for a profile (v1)
|
||||
"""
|
||||
self.run_test_profile_entries(
|
||||
version=settings.API_VERSIONS['version_1'],
|
||||
entry_type='published',
|
||||
)
|
||||
|
||||
def test_profile_v2_entries_published(self):
|
||||
"""
|
||||
Get the published entries for a profile (v2)
|
||||
"""
|
||||
self.run_test_profile_entries(
|
||||
version=settings.API_VERSIONS['version_2'],
|
||||
entry_type='published',
|
||||
)
|
||||
|
||||
def test_profile_v1_entries_favorited(self):
|
||||
"""
|
||||
Get the favorited entries for a profile (v1)
|
||||
"""
|
||||
self.run_test_profile_entries(
|
||||
version=settings.API_VERSIONS['version_1'],
|
||||
entry_type='favorited',
|
||||
)
|
||||
|
||||
def test_profile_v2_entries_favorited(self):
|
||||
"""
|
||||
Get the favorited entries for a profile (v2)
|
||||
"""
|
||||
self.run_test_profile_entries(
|
||||
version=settings.API_VERSIONS['version_2'],
|
||||
entry_type='favorited',
|
||||
)
|
||||
profile_json = json.loads(str(response.content, 'utf-8'))
|
||||
self.assertListEqual(profile_json['favorited'], serialized_entries)
|
||||
|
||||
def test_profile_entries_count(self):
|
||||
"""
|
||||
Get the number of entries associated with a profile
|
||||
"""
|
||||
profile = OrderedCreatorRecord.objects.filter(creator__profile__isnull=False)[:1].get().creator.profile
|
||||
published_entry = profile.related_creator.related_entries.last().entry
|
||||
published_entry.published_by = profile.user
|
||||
published_entry.save()
|
||||
favorited_entries = Entry.objects.public()[:2]
|
||||
user = BasicEmailUserFactory()
|
||||
profile = user.profile
|
||||
|
||||
self.client.force_login(user=profile.user)
|
||||
approved = ModerationState.objects.get(name='Approved')
|
||||
BasicEntryFactory(published_by=user, moderation_state=approved)
|
||||
published_entry_2 = BasicEntryFactory(published_by=user, moderation_state=approved, published_by_creator=True)
|
||||
|
||||
for entry in favorited_entries:
|
||||
self.client.put(reverse('bookmark', kwargs={'entryid': entry.id}))
|
||||
created_entry = BasicEntryFactory(published_by=self.user, moderation_state=approved)
|
||||
EntryCreatorFactory(profile=profile, entry=created_entry)
|
||||
|
||||
entry_count = Entry.objects.public().filter(
|
||||
Q(related_creators__creator__profile=profile) |
|
||||
Q(published_by=profile.user) |
|
||||
Q(bookmarked_by__profile=profile)
|
||||
).distinct().count()
|
||||
favorited_entry_1 = BasicEntryFactory(published_by=self.user, moderation_state=approved)
|
||||
favorited_entry_2 = BasicEntryFactory(published_by=self.user, moderation_state=approved)
|
||||
UserBookmarksFactory(profile=profile, entry=favorited_entry_1)
|
||||
UserBookmarksFactory(profile=profile, entry=favorited_entry_2)
|
||||
|
||||
# Overlapping entries (entries that belong to more than one of created, published, or favorited)
|
||||
# These entries should not be duplicated in the entry count
|
||||
EntryCreatorFactory(profile=profile, entry=published_entry_2)
|
||||
UserBookmarksFactory(profile=profile, entry=created_entry)
|
||||
|
||||
response = self.client.get(reverse('profile-entries', kwargs={'pk': profile.id}))
|
||||
profile_json = json.loads(str(response.content, 'utf-8'))
|
||||
self.assertEqual(profile_json['entry_count'], entry_count)
|
||||
response_profile = json.loads(str(response.content, 'utf-8'))
|
||||
self.assertEqual(response_profile['entry_count'], 5)
|
||||
|
||||
def test_extended_profile_data(self):
|
||||
(profile, created) = UserProfile.objects.get_or_create(related_user=self.user)
|
||||
|
@ -231,51 +279,48 @@ class TestProfileView(PulseMemberTestCase):
|
|||
(profile, created) = ProgramYear.objects.get_or_create(value='2018')
|
||||
self.assertEqual(created, False)
|
||||
|
||||
def test_profile_list_v1(self):
|
||||
(profile_type, _) = ProfileType.objects.get_or_create(value='test-type')
|
||||
for profile in UserProfile.objects.all()[:3]:
|
||||
profile.enable_extended_information = True
|
||||
profile.profile_type = profile_type
|
||||
def run_test_profile_list(self, api_version, profile_serializer_class, profile_params={}, query_dict=''):
|
||||
profile_type = profile_params.pop('profile_type', None)
|
||||
if not profile_type:
|
||||
(profile_type, _) = ProfileType.objects.get_or_create(value='temporary-type')
|
||||
for _ in range(3):
|
||||
profile = ExtendedUserProfileFactory(profile_type=profile_type, **profile_params)
|
||||
profile.thumbnail = None
|
||||
profile.save()
|
||||
|
||||
url = reverse('profile_list', args=[
|
||||
settings.API_VERSIONS['version_1'] + '/'
|
||||
])
|
||||
response = self.client.get('{url}?profile_type={type}'.format(url=url, type=profile_type.value))
|
||||
url = reverse('profile_list', args=[api_version + '/'])
|
||||
response = self.client.get('{url}?profile_type={type}&{qs}'.format(
|
||||
url=url,
|
||||
type=profile_type.value,
|
||||
qs=urlencode(query_dict)
|
||||
))
|
||||
response_profiles = json.loads(str(response.content, 'utf-8'))
|
||||
|
||||
profile_list = [
|
||||
UserProfilePublicWithEntriesSerializer(
|
||||
profile,
|
||||
context={'request': response.wsgi_request}
|
||||
).data
|
||||
for profile in UserProfile.objects.filter(profile_type=profile_type)
|
||||
]
|
||||
profile_list = profile_serializer_class(
|
||||
UserProfile.objects.filter(profile_type=profile_type),
|
||||
many=True,
|
||||
).data
|
||||
|
||||
self.assertListEqual(response_profiles, profile_list)
|
||||
|
||||
def test_profile_list_v1(self):
|
||||
self.run_test_profile_list(
|
||||
api_version=settings.API_VERSIONS['version_1'],
|
||||
profile_serializer_class=UserProfilePublicWithEntriesSerializer
|
||||
)
|
||||
|
||||
def test_profile_list_v2(self):
|
||||
(profile_type, _) = ProfileType.objects.get_or_create(value='test-type')
|
||||
for profile in UserProfile.objects.all()[:3]:
|
||||
profile.enable_extended_information = True
|
||||
profile.profile_type = profile_type
|
||||
profile.save()
|
||||
self.run_test_profile_list(
|
||||
api_version=settings.API_VERSIONS['version_2'],
|
||||
profile_serializer_class=UserProfilePublicSerializer
|
||||
)
|
||||
|
||||
url = reverse('profile_list', args=[
|
||||
settings.API_VERSIONS['version_2'] + '/'
|
||||
])
|
||||
response = self.client.get('{url}?profile_type={type}'.format(url=url, type=profile_type.value))
|
||||
response_profiles = json.loads(str(response.content, 'utf-8'))
|
||||
|
||||
profile_list = [
|
||||
UserProfilePublicSerializer(
|
||||
profile,
|
||||
context={'request': response.wsgi_request}
|
||||
).data
|
||||
for profile in UserProfile.objects.filter(profile_type=profile_type)
|
||||
]
|
||||
|
||||
self.assertListEqual(response_profiles, profile_list)
|
||||
def test_profile_list_basic_v2(self):
|
||||
self.run_test_profile_list(
|
||||
api_version=settings.API_VERSIONS['version_2'],
|
||||
profile_serializer_class=UserProfileBasicSerializer,
|
||||
query_dict={'basic': ''}
|
||||
)
|
||||
|
||||
def test_profile_list_filtering(self):
|
||||
profile_types = ['a', 'b', 'c']
|
||||
|
@ -332,6 +377,14 @@ class TestProfileView(PulseMemberTestCase):
|
|||
entriesjson = json.loads(str(response.content, 'utf-8'))
|
||||
self.assertEqual(len(entriesjson), 1)
|
||||
|
||||
def test_profile_list_filtering_active_v2(self):
|
||||
self.run_test_profile_list(
|
||||
api_version=settings.API_VERSIONS['version_2'],
|
||||
profile_serializer_class=UserProfilePublicSerializer,
|
||||
profile_params={'is_active': True},
|
||||
query_dict={'is_active': True},
|
||||
)
|
||||
|
||||
def test_invalid_profile_list_filtering_argument(self):
|
||||
profile_url = reverse('profile_list')
|
||||
url = ('{url}?unsupported_arg=should_be_empty_response').format(url=profile_url)
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import base64
|
||||
import django_filters
|
||||
|
||||
from itertools import chain
|
||||
from django.core.files.base import ContentFile
|
||||
from django.conf import settings
|
||||
|
||||
from django.db.models import Q
|
||||
from rest_framework import filters, permissions
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
@ -20,6 +21,11 @@ from pulseapi.profiles.serializers import (
|
|||
UserProfilePublicSerializer,
|
||||
UserProfilePublicWithEntriesSerializer,
|
||||
UserProfileEntriesSerializer,
|
||||
UserProfileBasicSerializer,
|
||||
)
|
||||
from pulseapi.entries.serializers import (
|
||||
EntryWithCreatorsBaseSerializer,
|
||||
EntryWithV1CreatorsBaseSerializer,
|
||||
)
|
||||
|
||||
|
||||
|
@ -32,10 +38,10 @@ class UserProfilePublicAPIView(RetrieveAPIView):
|
|||
queryset = UserProfile.objects.all()
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.request.version == settings.API_VERSIONS['version_2']:
|
||||
return UserProfilePublicSerializer
|
||||
if self.request.version == settings.API_VERSIONS['version_1']:
|
||||
return UserProfilePublicWithEntriesSerializer
|
||||
|
||||
return UserProfilePublicWithEntriesSerializer
|
||||
return UserProfilePublicSerializer
|
||||
|
||||
|
||||
class UserProfilePublicSelfAPIView(UserProfilePublicAPIView):
|
||||
|
@ -95,19 +101,21 @@ class UserProfileEntriesAPIView(APIView):
|
|||
a creator on, was a publisher of, or favorited.
|
||||
"""
|
||||
profile = get_object_or_404(
|
||||
UserProfile.objects.select_related(
|
||||
'related_creator',
|
||||
'related_user'
|
||||
),
|
||||
UserProfile.objects.select_related('related_user'),
|
||||
pk=pk,
|
||||
)
|
||||
query = request.query_params
|
||||
EntrySerializerClass = EntryWithCreatorsBaseSerializer
|
||||
|
||||
if request and request.version == settings.API_VERSIONS['version_1']:
|
||||
EntrySerializerClass = EntryWithV1CreatorsBaseSerializer
|
||||
|
||||
return Response(
|
||||
UserProfileEntriesSerializer(instance=profile, context={
|
||||
'created': 'created' in query,
|
||||
'published': 'published' in query,
|
||||
'favorited': 'favorited' in query
|
||||
'favorited': 'favorited' in query,
|
||||
'EntrySerializerClass': EntrySerializerClass
|
||||
}).data
|
||||
)
|
||||
|
||||
|
@ -136,6 +144,15 @@ class ProfileCustomFilter(filters.FilterSet):
|
|||
name='program_year__value',
|
||||
lookup_expr='iexact',
|
||||
)
|
||||
name = django_filters.CharFilter(method='filter_name')
|
||||
|
||||
def filter_name(self, queryset, name, value):
|
||||
startswith_lookup = Q(custom_name__istartswith=value) | Q(related_user__name__istartswith=value)
|
||||
qs_startswith = queryset.filter(startswith_lookup)
|
||||
qs_contains = queryset.filter(
|
||||
Q(custom_name__icontains=value) | Q(related_user__name__icontains=value)
|
||||
).exclude(startswith_lookup)
|
||||
return list(chain(qs_startswith, qs_contains))
|
||||
|
||||
@property
|
||||
def qs(self):
|
||||
|
@ -150,7 +167,7 @@ class ProfileCustomFilter(filters.FilterSet):
|
|||
if request is None:
|
||||
return empty_set
|
||||
|
||||
queries = self.request.GET
|
||||
queries = self.request.query_params
|
||||
if queries is None:
|
||||
return empty_set
|
||||
|
||||
|
@ -170,6 +187,8 @@ class ProfileCustomFilter(filters.FilterSet):
|
|||
'profile_type',
|
||||
'program_type',
|
||||
'program_year',
|
||||
'is_active',
|
||||
'name',
|
||||
]
|
||||
|
||||
|
||||
|
@ -179,7 +198,9 @@ class UserProfileListAPIView(ListAPIView):
|
|||
profile_type=
|
||||
program_type=
|
||||
program_year=
|
||||
is_active=(True or False)
|
||||
ordering=(custom_name, program_year) or negative (e.g. -custom_name) to reverse.
|
||||
basic=
|
||||
"""
|
||||
filter_backends = (
|
||||
filters.DjangoFilterBackend,
|
||||
|
@ -199,7 +220,11 @@ class UserProfileListAPIView(ListAPIView):
|
|||
)
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.request and self.request.version == settings.API_VERSIONS['version_2']:
|
||||
return UserProfilePublicSerializer
|
||||
request = self.request
|
||||
|
||||
return UserProfilePublicWithEntriesSerializer
|
||||
if request and request.version == settings.API_VERSIONS['version_1']:
|
||||
return UserProfilePublicWithEntriesSerializer
|
||||
|
||||
if 'basic' in request.query_params:
|
||||
return UserProfileBasicSerializer
|
||||
return UserProfilePublicSerializer
|
||||
|
|
|
@ -7,10 +7,9 @@ from rest_framework import exceptions
|
|||
|
||||
from pulseapi.settings import API_VERSION_LIST
|
||||
from pulseapi.users.models import EmailUser
|
||||
from pulseapi.users.test_models import EmailUserFactory
|
||||
from pulseapi.profiles.test_models import UserProfileFactory
|
||||
from pulseapi.creators.models import OrderedCreatorRecord
|
||||
from pulseapi.creators.factory import CreatorFactory
|
||||
from pulseapi.users.factory import BasicEmailUserFactory
|
||||
from pulseapi.profiles.factory import BasicUserProfileFactory
|
||||
from pulseapi.creators.factory import EntryCreatorFactory
|
||||
from pulseapi.entries.factory import EntryFactory
|
||||
from pulseapi.versioning import PulseAPIVersioning
|
||||
|
||||
|
@ -47,16 +46,15 @@ def setup_entries(test, creator_users):
|
|||
entry = EntryFactory()
|
||||
entry.save()
|
||||
|
||||
# Create a simple creator that has no profile
|
||||
creators = [CreatorFactory()]
|
||||
creators = [BasicUserProfileFactory(use_custom_name=True)]
|
||||
if creator_users and len(creator_users) > i:
|
||||
# If we were passed in users, create a creator attached to a user profile
|
||||
for user in creator_users:
|
||||
creators.append(user.profile.related_creator)
|
||||
creators.append(user.profile)
|
||||
for creator in creators:
|
||||
creator.save()
|
||||
# Connect the creator with the entry
|
||||
OrderedCreatorRecord(entry=entry, creator=creator).save()
|
||||
EntryCreatorFactory(entry=entry, profile=creator)
|
||||
|
||||
test.creators.extend(creators)
|
||||
test.entries.append(entry)
|
||||
|
@ -64,11 +62,12 @@ def setup_entries(test, creator_users):
|
|||
|
||||
def setup_users_with_profiles(test):
|
||||
users = []
|
||||
profiles = [UserProfileFactory(is_active=True) for i in range(3)]
|
||||
profiles = [
|
||||
BasicUserProfileFactory(active=True, use_custom_name=(i % 2 == 0))
|
||||
for i in range(3)
|
||||
]
|
||||
for profile in profiles:
|
||||
profile.save()
|
||||
user = EmailUserFactory(profile=profile)
|
||||
user.save()
|
||||
user = BasicEmailUserFactory(profile=profile)
|
||||
users.append(user)
|
||||
|
||||
test.users_with_profiles = users
|
||||
|
|
|
@ -7,7 +7,7 @@ urlpatterns = [
|
|||
url(r'^login$', views.start_auth, name='login'),
|
||||
url(r'^logout', views.force_logout, name='logout'),
|
||||
url(r'^oauth2callback', views.callback, name='oauthcallback'),
|
||||
url(r'^nonce', views.nonce, name="get a new nonce value"),
|
||||
url(r'^userstatus', views.userstatus, name="get current user information"),
|
||||
url(r'^nonce', views.nonce, name="nonce"),
|
||||
url(r'^userstatus', views.userstatus, name="user-status"),
|
||||
url(r'^status/', views.api_status, name='api-status'),
|
||||
]
|
||||
|
|
|
@ -4,7 +4,7 @@ set of fake data.
|
|||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from pulseapi.creators.models import Creator, OrderedCreatorRecord
|
||||
from pulseapi.creators.models import EntryCreator
|
||||
from pulseapi.entries.models import Entry
|
||||
from pulseapi.profiles.models import UserBookmarks, UserProfile
|
||||
from pulseapi.tags.models import Tag
|
||||
|
@ -29,9 +29,8 @@ class Command(BaseCommand):
|
|||
self.stdout.write('Dropping Bookmarks objects')
|
||||
UserBookmarks.objects.all().delete()
|
||||
|
||||
self.stdout.write('Dropping Creators objects')
|
||||
Creator.objects.all().delete()
|
||||
OrderedCreatorRecord.objects.all().delete()
|
||||
self.stdout.write('Dropping EntryCreator objects')
|
||||
EntryCreator.objects.all().delete()
|
||||
|
||||
self.stdout.write('Dropping Profile objects')
|
||||
UserProfile.objects.all().delete()
|
||||
|
|
|
@ -10,7 +10,7 @@ from django.core.management import call_command
|
|||
from pulseapi.entries.models import Entry
|
||||
|
||||
# Factories
|
||||
from pulseapi.creators.factory import OrderedCreatorRecordFactory, CreatorFactory
|
||||
from pulseapi.creators.factory import EntryCreatorFactory
|
||||
from pulseapi.entries.factory import BasicEntryFactory, GetInvolvedEntryFactory
|
||||
from pulseapi.profiles.factory import UserBookmarksFactory
|
||||
from pulseapi.tags.factory import TagFactory
|
||||
|
@ -89,15 +89,12 @@ class Command(BaseCommand):
|
|||
# Select random published entries and bookmark them for 1 to 10 users
|
||||
self.stdout.write('Creating bookmarks')
|
||||
approved_entries = Entry.objects.public().with_related()
|
||||
for e in sample(list(approved_entries), k=len(approved_entries)//2):
|
||||
for e in sample(list(approved_entries), k=len(approved_entries) // 2):
|
||||
[UserBookmarksFactory.create(entry=e) for i in range(randint(1, 10))]
|
||||
|
||||
self.stdout.write('Creating creators')
|
||||
[CreatorFactory.create() for i in range(5)]
|
||||
|
||||
# Select random entries and link them to 1 to 5 creators
|
||||
self.stdout.write('Linking random creators with random entries')
|
||||
self.stdout.write('Linking random profiles as creators with random entries')
|
||||
for e in sample(list(Entry.objects.all()), k=100):
|
||||
[OrderedCreatorRecordFactory.create(entry=e) for i in range(randint(1, 5))]
|
||||
[EntryCreatorFactory.create(entry=e) for i in range(randint(1, 5))]
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Done!'))
|
||||
|
|
|
@ -8,14 +8,14 @@ from pulseapi import settings
|
|||
from pulseapi.entries.models import Entry
|
||||
|
||||
|
||||
# The creator(s) name can be found in the `OrderedCreatorRecord` class from the `creators` models.
|
||||
# The creator(s) name can be found in the `EntryCreator` class from the `creators` models.
|
||||
def get_entry_creators(entry):
|
||||
# Since `creators` is an optional field and can be empty, we return the publisher name instead.
|
||||
if entry.related_creators.count() >= 1:
|
||||
entry_creators = entry.related_entry_creators.all()
|
||||
if len(entry_creators) >= 1:
|
||||
return ', '.join(
|
||||
creator_record.creator.creator_name
|
||||
for creator_record
|
||||
in entry.related_creators.all()
|
||||
entry_creator.profile.name
|
||||
for entry_creator in entry_creators
|
||||
)
|
||||
else:
|
||||
return entry.published_by.name
|
||||
|
|
|
@ -4,14 +4,13 @@ from django.test import TestCase
|
|||
from django.core.management import call_command
|
||||
|
||||
from pulseapi.entries.models import Entry
|
||||
from pulseapi.creators.models import Creator, OrderedCreatorRecord
|
||||
from pulseapi.creators.models import EntryCreator
|
||||
from pulseapi.profiles.models import UserBookmarks, UserProfile
|
||||
from pulseapi.tags.models import Tag
|
||||
from pulseapi.users.models import EmailUser
|
||||
|
||||
models = [
|
||||
Creator,
|
||||
OrderedCreatorRecord,
|
||||
EntryCreator,
|
||||
Entry,
|
||||
UserBookmarks,
|
||||
UserProfile,
|
||||
|
|
Загрузка…
Ссылка в новой задаче