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:
Gideon Thomas 2018-04-26 15:13:17 -04:00 коммит произвёл GitHub
Родитель bc0c05c92a
Коммит 89a46577f1
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
31 изменённых файлов: 1074 добавлений и 765 удалений

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,