Repository: saleor/saleor
Pull request: 3134/description field to collection model
I want to merge this change because resolve #3134

<!-- Please mention all relevant issue numbers. -->

### Screenshots

<!-- If your changes affect the UI, providing "before" and "after" screenshots will
greatly reduce the amount of work needed to review your work. -->

### Pull Request Checklist

<!-- Please keep this section. It will make maintainer's life easier. -->

1. [x] Privileged views and APIs are guarded by proper permission checks.
1. [x] All visible strings are translated with proper context.
1. [x] All data-formatting is locale-aware (dates, numbers, and so on).
1. [x] Database queries are optimized and the number of queries is constant.
1. [x] The changes are tested.
1. [x] The code is documented (docstrings, project documentation).
1. [x] GraphQL schema and type definitions are up to date.


Problem statement:
Add description field to collection model
### What I'm trying to achieve
To add a description to a collection.

### Describe a proposed solution
Add `description` field to `Collection` model.

### TODO 
- [ ] Add description field to `Collection` model
- [ ] Add description field to `CollectionTranslation` model
- [ ] Adding migrations
- [ ] Add `description` to Collection's forms & templates in the dashboard
- [ ] Adding `description` field to Collection's GraphQL APIs (mutations and types)
- [ ] Adjust "SEO Widget" logic on the dashboard, on Collection Add/Edit page (description should be used as a placeholder if seo_description is missing)
- [ ] Adjust tests for all of the above

Hints from issue discussion:
```
class Collection(SeoModel):
    name = models.CharField(max_length=128, unique=True)
    slug = models.SlugField(max_length=128)
    products = models.ManyToManyField(
        Product, blank=True, related_name='collections')
    background_image = VersatileImageField(
        upload_to='collection-backgrounds', blank=True, null=True)
    is_published = models.BooleanField(default=False)
    description = models.TextField(blank=True, null=True)

    objects = CollectionQuerySet.as_manager()
    translated = TranslationProxy()
    
    class Meta:
        ordering = ['pk']
    
    def __str__(self):
        return self.name
    
    def get_absolute_url(self):
        return reverse(
            'product:collection',
            kwargs={'pk': self.id, 'slug': self.slug})
```

Then run 
```
python manage.py makemigrations
python manage.py migrate
```
# Detailed Explanation 
- Add description field to collection model
- adding migrations
- expanding forms in the dashboard
- adding this field to GraphQL APIs
- adjust "SEO Widget" logic on the Collection form

@gagantrivedi go for it!

Commit message: Sort import

Patch to review:
diff --git a/saleor/core/utils/random_data.py b/saleor/core/utils/random_data.py
index 5b2ed0bf96f..69105a6a63a 100644
--- a/saleor/core/utils/random_data.py
+++ b/saleor/core/utils/random_data.py
@@ -5,6 +5,7 @@
 import uuid
 from collections import defaultdict
 from datetime import date
+from textwrap import dedent
 from unittest.mock import patch
 
 from django.conf import settings
@@ -49,10 +50,17 @@
 COLLECTIONS_SCHEMA = [
     {
         'name': 'Summer collection',
-        'image_name': 'summer.jpg'},
+        'image_name': 'summer.jpg',
+        'description': dedent('''The Saleor Summer Collection features a range
+            of products that feel the heat of the market. A demo store for all
+            seasons. Saleor captures the open source, e-commerce sun.''')},
     {
         'name': 'Winter sale',
-        'image_name': 'clothing.jpg'}]
+        'image_name': 'clothing.jpg',
+        'description': dedent('''The Saleor Winter Sale is snowed under with
+            seasonal offers. Unreal products at unreal prices. Literally,
+            they are not real products, but the Saleor demo store is a
+            genuine e-commerce leader.''')}]
 
 IMAGES_MAPPING = {
     61: ['saleordemoproduct_paints_01.png'],
@@ -239,11 +247,12 @@ def get_email(first_name, last_name):
         _first.lower().decode('utf-8'), _last.lower().decode('utf-8'))
 
 
-def get_or_create_collection(name, placeholder_dir, image_name):
+def get_or_create_collection(name, placeholder_dir, image_name, description):
     background_image = get_image(placeholder_dir, image_name)
     defaults = {
         'slug': fake.slug(name),
-        'background_image': background_image}
+        'background_image': background_image,
+        'description': description}
     return Collection.objects.get_or_create(name=name, defaults=defaults)[0]
 
 
@@ -528,7 +537,8 @@ def create_fake_collection(placeholder_dir, collection_data):
     image_dir = get_product_list_images_dir(placeholder_dir)
     collection = get_or_create_collection(
         name=collection_data['name'], placeholder_dir=image_dir,
-        image_name=collection_data['image_name'])
+        image_name=collection_data['image_name'],
+        description=collection_data['description'])
     products = Product.objects.order_by('?')[:4]
     collection.products.add(*products)
     create_collection_background_image_thumbnails.delay(collection.pk)
diff --git a/saleor/dashboard/collection/forms.py b/saleor/dashboard/collection/forms.py
index 7a9b36a7724..67dd0893bd5 100644
--- a/saleor/dashboard/collection/forms.py
+++ b/saleor/dashboard/collection/forms.py
@@ -23,17 +23,21 @@ class Meta:
         labels = {
             'name': pgettext_lazy('Item name', 'Name'),
             'background_image': pgettext_lazy(
-                'Products selection',
-                'Background Image'),
+                'Background image of a collection',
+                'Background image'),
             'is_published': pgettext_lazy(
                 'Collection published toggle',
-                'Published')}
+                'Published'),
+            'description': pgettext_lazy(
+                'Description field of a collection',
+                'Description')}
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         if self.instance.pk:
             self.fields['products'].set_initial(self.instance.products.all())
-        self.fields['seo_description'] = SeoDescriptionField()
+        self.fields['seo_description'] = SeoDescriptionField(
+            extra_attrs={'data-bind': self['description'].auto_id})
         self.fields['seo_title'] = SeoTitleField(
             extra_attrs={'data-bind': self['name'].auto_id})
 
diff --git a/saleor/graphql/product/mutations/products.py b/saleor/graphql/product/mutations/products.py
index 7c8f891b3d0..3c41ea29348 100644
--- a/saleor/graphql/product/mutations/products.py
+++ b/saleor/graphql/product/mutations/products.py
@@ -109,6 +109,7 @@ class CollectionInput(graphene.InputObjectType):
         description='Informs whether a collection is published.')
     name = graphene.String(description='Name of the collection.')
     slug = graphene.String(description='Slug of the collection.')
+    description = graphene.String(description='Description of the collection.')
     background_image = Upload(description='Background image file.')
     seo = SeoInput(description='Search engine optimization fields.')
 
diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql
index a3cecbf2da1..6c65f055601 100644
--- a/saleor/graphql/schema.graphql
+++ b/saleor/graphql/schema.graphql
@@ -419,6 +419,7 @@ type Collection implements Node {
   products(before: String, after: String, first: Int, last: Int): ProductCountableConnection
   backgroundImage: Image
   isPublished: Boolean!
+  description: String
 }
 
 type CollectionAddProducts {
@@ -446,6 +447,7 @@ input CollectionCreateInput {
   isPublished: Boolean
   name: String
   slug: String
+  description: String
   backgroundImage: Upload
   seo: SeoInput
   products: [ID]
@@ -460,6 +462,7 @@ input CollectionInput {
   isPublished: Boolean
   name: String
   slug: String
+  description: String
   backgroundImage: Upload
   seo: SeoInput
 }
@@ -833,6 +836,17 @@ type MoneyRange {
 }
 
 type Mutations {
+  authorizationKeyAdd(input: AuthorizationKeyInput!, keyType: AuthorizationKeyType!): AuthorizationKeyAdd
+  authorizationKeyDelete(keyType: AuthorizationKeyType!): AuthorizationKeyDelete
+  homepageCollectionUpdate(collection: ID): HomepageCollectionUpdate
+  shopDomainUpdate(input: SiteDomainInput): ShopDomainUpdate
+  shopSettingsUpdate(input: ShopSettingsInput!): ShopSettingsUpdate
+  shippingPriceCreate(input: ShippingPriceInput!): ShippingPriceCreate
+  shippingPriceDelete(id: ID!): ShippingPriceDelete
+  shippingPriceUpdate(id: ID!, input: ShippingPriceInput!): ShippingPriceUpdate
+  shippingZoneCreate(input: ShippingZoneInput!): ShippingZoneCreate
+  shippingZoneDelete(id: ID!): ShippingZoneDelete
+  shippingZoneUpdate(id: ID!, input: ShippingZoneInput!): ShippingZoneUpdate
   attributeCreate(id: ID!, input: AttributeCreateInput!, type: AttributeTypeEnum!): AttributeCreate
   attributeDelete(id: ID!): AttributeDelete
   attributeUpdate(id: ID!, input: AttributeUpdateInput!): AttributeUpdate
@@ -842,103 +856,92 @@ type Mutations {
   categoryCreate(input: CategoryInput!, parent: ID): CategoryCreate
   categoryDelete(id: ID!): CategoryDelete
   categoryUpdate(id: ID!, input: CategoryInput!): CategoryUpdate
+  collectionAddProducts(collectionId: ID!, products: [ID]!): CollectionAddProducts
   collectionCreate(input: CollectionCreateInput!): CollectionCreate
-  collectionUpdate(id: ID!, input: CollectionInput!): CollectionUpdate
   collectionDelete(id: ID!): CollectionDelete
-  collectionAddProducts(collectionId: ID!, products: [ID]!): CollectionAddProducts
   collectionRemoveProducts(collectionId: ID!, products: [ID]!): CollectionRemoveProducts
+  collectionUpdate(id: ID!, input: CollectionInput!): CollectionUpdate
   productCreate(input: ProductCreateInput!): ProductCreate
   productDelete(id: ID!): ProductDelete
   productUpdate(id: ID!, input: ProductInput!): ProductUpdate
   productImageCreate(input: ProductImageCreateInput!): ProductImageCreate
-  productImageReorder(imagesIds: [ID]!, productId: ID!): ProductImageReorder
   productImageDelete(id: ID!): ProductImageDelete
+  productImageReorder(imagesIds: [ID]!, productId: ID!): ProductImageReorder
   productImageUpdate(id: ID!, input: ProductImageUpdateInput!): ProductImageUpdate
   productTypeCreate(input: ProductTypeInput!): ProductTypeCreate
-  productTypeUpdate(id: ID!, input: ProductTypeInput!): ProductTypeUpdate
   productTypeDelete(id: ID!): ProductTypeDelete
+  productTypeUpdate(id: ID!, input: ProductTypeInput!): ProductTypeUpdate
   productVariantCreate(input: ProductVariantCreateInput!): ProductVariantCreate
   productVariantDelete(id: ID!): ProductVariantDelete
   productVariantUpdate(id: ID!, input: ProductVariantInput!): ProductVariantUpdate
   variantImageAssign(imageId: ID!, variantId: ID!): VariantImageAssign
   variantImageUnassign(imageId: ID!, variantId: ID!): VariantImageUnassign
-  authorizationKeyAdd(input: AuthorizationKeyInput!, keyType: AuthorizationKeyType!): AuthorizationKeyAdd
-  authorizationKeyDelete(keyType: AuthorizationKeyType!): AuthorizationKeyDelete
-  assignNavigation(menu: ID, navigationType: NavigationType!): AssignNavigation
-  tokenCreate(email: String!, password: String!): CreateToken
-  tokenRefresh(token: String!): Refresh
-  tokenVerify(token: String!): VerifyToken
-  setPassword(id: ID!, input: SetPasswordInput!): SetPassword
-  passwordReset(email: String!): PasswordReset
-  customerCreate(input: UserCreateInput!): CustomerCreate
-  loggedUserUpdate(input: UserAddressInput!): LoggedUserUpdate
-  customerUpdate(id: ID!, input: CustomerInput!): CustomerUpdate
-  customerDelete(id: ID!): CustomerDelete
-  customerPasswordReset(input: CustomerPasswordResetInput!): CustomerPasswordReset
-  customerRegister(input: CustomerRegisterInput!): CustomerRegister
-  staffCreate(input: StaffCreateInput!): StaffCreate
-  staffUpdate(id: ID!, input: StaffInput!): StaffUpdate
-  staffDelete(id: ID!): StaffDelete
-  addressCreate(input: AddressCreateInput!): AddressCreate
-  addressUpdate(id: ID!, input: AddressInput!): AddressUpdate
-  addressDelete(id: ID!): AddressDelete
-  checkoutCreate(input: CheckoutCreateInput!): CheckoutCreate
-  checkoutLinesAdd(checkoutId: ID!, lines: [CheckoutLineInput]!): CheckoutLinesAdd
-  checkoutLinesUpdate(checkoutId: ID!, lines: [CheckoutLineInput]!): CheckoutLinesUpdate
-  checkoutLineDelete(checkoutId: ID!, lineId: ID): CheckoutLineDelete
-  checkoutCustomerAttach(checkoutId: ID!, customerId: ID!): CheckoutCustomerAttach
-  checkoutCustomerDetach(checkoutId: ID!): CheckoutCustomerDetach
-  checkoutBillingAddressUpdate(billingAddress: AddressInput, checkoutId: ID): CheckoutBillingAddressUpdate
-  checkoutShippingAddressUpdate(checkoutId: ID, shippingAddress: AddressInput): CheckoutShippingAddressUpdate
-  checkoutShippingMethodUpdate(checkoutId: ID, shippingMethodId: ID!): CheckoutShippingMethodUpdate
-  checkoutEmailUpdate(checkoutId: ID, email: String!): CheckoutEmailUpdate
-  checkoutPaymentCreate(input: PaymentInput!): CheckoutPaymentCreate
-  checkoutComplete(checkoutId: ID): CheckoutComplete
-  menuCreate(input: MenuCreateInput!): MenuCreate
-  menuDelete(id: ID!): MenuDelete
-  menuUpdate(id: ID!, input: MenuInput!): MenuUpdate
-  menuItemCreate(input: MenuItemCreateInput!): MenuItemCreate
-  menuItemDelete(id: ID!): MenuItemDelete
-  menuItemUpdate(id: ID!, input: MenuItemInput!): MenuItemUpdate
-  draftOrderCreate(input: DraftOrderCreateInput!): DraftOrderCreate
+  paymentCapture(amount: Decimal, paymentId: ID!): PaymentCapture
+  paymentRefund(amount: Decimal, paymentId: ID!): PaymentRefund
+  paymentVoid(paymentId: ID!): PaymentVoid
+  pageCreate(input: PageInput!): PageCreate
+  pageDelete(id: ID!): PageDelete
+  pageUpdate(id: ID!, input: PageInput!): PageUpdate
   draftOrderComplete(id: ID!): DraftOrderComplete
+  draftOrderCreate(input: DraftOrderCreateInput!): DraftOrderCreate
   draftOrderDelete(id: ID!): DraftOrderDelete
   draftOrderLineCreate(id: ID!, input: OrderLineCreateInput!): DraftOrderLineCreate
   draftOrderLineDelete(id: ID!): DraftOrderLineDelete
   draftOrderLineUpdate(id: ID!, input: OrderLineInput!): DraftOrderLineUpdate
   draftOrderUpdate(id: ID!, input: DraftOrderInput!): DraftOrderUpdate
-  orderFulfillmentCancel(id: ID!, input: FulfillmentCancelInput!): FulfillmentCancel
-  orderFulfillmentCreate(input: FulfillmentCreateInput!, order: ID): FulfillmentCreate
-  orderFulfillmentUpdateTracking(id: ID!, input: FulfillmentUpdateTrackingInput!): FulfillmentUpdateTracking
   orderAddNote(order: ID!, input: OrderAddNoteInput!): OrderAddNote
   orderCancel(id: ID!, restock: Boolean!): OrderCancel
   orderCapture(amount: Decimal!, id: ID!): OrderCapture
+  orderFulfillmentCancel(id: ID!, input: FulfillmentCancelInput!): FulfillmentCancel
+  orderFulfillmentCreate(input: FulfillmentCreateInput!, order: ID): FulfillmentCreate
+  orderFulfillmentUpdateTracking(id: ID!, input: FulfillmentUpdateTrackingInput!): FulfillmentUpdateTracking
   orderMarkAsPaid(id: ID!): OrderMarkAsPaid
-  orderUpdateShipping(order: ID!, input: OrderUpdateShippingInput): OrderUpdateShipping
   orderRefund(amount: Decimal!, id: ID!): OrderRefund
-  orderVoid(id: ID!): OrderVoid
   orderUpdate(id: ID!, input: OrderUpdateInput!): OrderUpdate
-  pageCreate(input: PageInput!): PageCreate
-  pageDelete(id: ID!): PageDelete
-  pageUpdate(id: ID!, input: PageInput!): PageUpdate
-  paymentCapture(amount: Decimal, paymentId: ID!): PaymentCapture
-  paymentRefund(amount: Decimal, paymentId: ID!): PaymentRefund
-  paymentVoid(paymentId: ID!): PaymentVoid
+  orderUpdateShipping(order: ID!, input: OrderUpdateShippingInput): OrderUpdateShipping
+  orderVoid(id: ID!): OrderVoid
+  assignNavigation(menu: ID, navigationType: NavigationType!): AssignNavigation
+  menuCreate(input: MenuCreateInput!): MenuCreate
+  menuDelete(id: ID!): MenuDelete
+  menuUpdate(id: ID!, input: MenuInput!): MenuUpdate
+  menuItemCreate(input: MenuItemCreateInput!): MenuItemCreate
+  menuItemDelete(id: ID!): MenuItemDelete
+  menuItemUpdate(id: ID!, input: MenuItemInput!): MenuItemUpdate
   saleCreate(input: SaleInput!): SaleCreate
   saleDelete(id: ID!): SaleDelete
   saleUpdate(id: ID!, input: SaleInput!): SaleUpdate
-  shopDomainUpdate(input: SiteDomainInput): ShopDomainUpdate
-  shopSettingsUpdate(input: ShopSettingsInput!): ShopSettingsUpdate
-  homepageCollectionUpdate(collection: ID): HomepageCollectionUpdate
   voucherCreate(input: VoucherInput!): VoucherCreate
   voucherDelete(id: ID!): VoucherDelete
   voucherUpdate(id: ID!, input: VoucherInput!): VoucherUpdate
-  shippingZoneCreate(input: ShippingZoneInput!): ShippingZoneCreate
-  shippingZoneDelete(id: ID!): ShippingZoneDelete
-  shippingZoneUpdate(id: ID!, input: ShippingZoneInput!): ShippingZoneUpdate
-  shippingPriceCreate(input: ShippingPriceInput!): ShippingPriceCreate
-  shippingPriceDelete(id: ID!): ShippingPriceDelete
-  shippingPriceUpdate(id: ID!, input: ShippingPriceInput!): ShippingPriceUpdate
+  tokenCreate(email: String!, password: String!): CreateToken
+  tokenRefresh(token: String!): Refresh
+  tokenVerify(token: String!): VerifyToken
+  checkoutBillingAddressUpdate(billingAddress: AddressInput, checkoutId: ID): CheckoutBillingAddressUpdate
+  checkoutComplete(checkoutId: ID): CheckoutComplete
+  checkoutCreate(input: CheckoutCreateInput!): CheckoutCreate
+  checkoutCustomerAttach(checkoutId: ID!, customerId: ID!): CheckoutCustomerAttach
+  checkoutCustomerDetach(checkoutId: ID!): CheckoutCustomerDetach
+  checkoutEmailUpdate(checkoutId: ID, email: String!): CheckoutEmailUpdate
+  checkoutLineDelete(checkoutId: ID!, lineId: ID): CheckoutLineDelete
+  checkoutLinesAdd(checkoutId: ID!, lines: [CheckoutLineInput]!): CheckoutLinesAdd
+  checkoutLinesUpdate(checkoutId: ID!, lines: [CheckoutLineInput]!): CheckoutLinesUpdate
+  checkoutPaymentCreate(input: PaymentInput!): CheckoutPaymentCreate
+  checkoutShippingAddressUpdate(checkoutId: ID, shippingAddress: AddressInput): CheckoutShippingAddressUpdate
+  checkoutShippingMethodUpdate(checkoutId: ID, shippingMethodId: ID!): CheckoutShippingMethodUpdate
+  passwordReset(email: String!): PasswordReset
+  setPassword(id: ID!, input: SetPasswordInput!): SetPassword
+  customerCreate(input: UserCreateInput!): CustomerCreate
+  customerDelete(id: ID!): CustomerDelete
+  customerPasswordReset(input: CustomerPasswordResetInput!): CustomerPasswordReset
+  customerRegister(input: CustomerRegisterInput!): CustomerRegister
+  customerUpdate(id: ID!, input: CustomerInput!): CustomerUpdate
+  loggedUserUpdate(input: UserAddressInput!): LoggedUserUpdate
+  staffCreate(input: StaffCreateInput!): StaffCreate
+  staffDelete(id: ID!): StaffDelete
+  staffUpdate(id: ID!, input: StaffInput!): StaffUpdate
+  addressCreate(input: AddressCreateInput!): AddressCreate
+  addressDelete(id: ID!): AddressDelete
+  addressUpdate(id: ID!, input: AddressInput!): AddressUpdate
 }
 
 type Navigation {
@@ -1576,6 +1579,9 @@ type ProductVariantUpdate {
 }
 
 type Query {
+  shop: Shop
+  shippingZone(id: ID!): ShippingZone
+  shippingZones(before: String, after: String, first: Int, last: Int): ShippingZoneCountableConnection
   attributes(query: String, inCategory: ID, before: String, after: String, first: Int, last: Int): AttributeCountableConnection
   categories(query: String, level: Int, before: String, after: String, first: Int, last: Int): CategoryCountableConnection
   category(id: ID!): Category
@@ -1588,34 +1594,31 @@ type Query {
   productVariant(id: ID!): ProductVariant
   productVariants(ids: [ID], before: String, after: String, first: Int, last: Int): ProductVariantCountableConnection
   reportProductSales(period: ReportingPeriod!, before: String, after: String, first: Int, last: Int): ProductVariantCountableConnection
-  addressValidator(input: AddressValidationInput!): AddressValidationData
-  checkout(token: UUID): Checkout
-  checkouts(before: String, after: String, first: Int, last: Int): CheckoutCountableConnection
-  checkoutLines(before: String, after: String, first: Int, last: Int): CheckoutLineCountableConnection
-  checkoutLine(id: ID): CheckoutLine
+  payment(id: ID): Payment
+  payments(before: String, after: String, first: Int, last: Int): PaymentCountableConnection
+  paymentClientToken(gateway: GatewaysEnum): String
+  page(id: ID, slug: String): Page
+  pages(query: String, before: String, after: String, first: Int, last: Int): PageCountableConnection
+  homepageEvents(before: String, after: String, first: Int, last: Int): OrderEventCountableConnection
+  order(id: ID!): Order
+  orders(query: String, created: ReportingPeriod, status: OrderStatusFilter, before: String, after: String, first: Int, last: Int): OrderCountableConnection
+  ordersTotal(period: ReportingPeriod): TaxedMoney
   menu(id: ID, name: String): Menu
   menus(query: String, before: String, after: String, first: Int, last: Int): MenuCountableConnection
   menuItem(id: ID!): MenuItem
   menuItems(query: String, before: String, after: String, first: Int, last: Int): MenuItemCountableConnection
-  order(id: ID!): Order
-  ordersTotal(period: ReportingPeriod): TaxedMoney
-  orders(query: String, created: ReportingPeriod, status: OrderStatusFilter, before: String, after: String, first: Int, last: Int): OrderCountableConnection
-  homepageEvents(before: String, after: String, first: Int, last: Int): OrderEventCountableConnection
-  page(id: ID, slug: String): Page
-  pages(query: String, before: String, after: String, first: Int, last: Int): PageCountableConnection
-  payment(id: ID): Payment
-  paymentClientToken(gateway: GatewaysEnum): String
-  payments(before: String, after: String, first: Int, last: Int): PaymentCountableConnection
   sale(id: ID!): Sale
   sales(query: String, before: String, after: String, first: Int, last: Int): SaleCountableConnection
-  shop: Shop
   voucher(id: ID!): Voucher
   vouchers(query: String, before: String, after: String, first: Int, last: Int): VoucherCountableConnection
-  shippingZone(id: ID!): ShippingZone
-  shippingZones(before: String, after: String, first: Int, last: Int): ShippingZoneCountableConnection
-  user(id: ID!): User
+  checkout(token: UUID): Checkout
+  checkouts(before: String, after: String, first: Int, last: Int): CheckoutCountableConnection
+  checkoutLine(id: ID): CheckoutLine
+  checkoutLines(before: String, after: String, first: Int, last: Int): CheckoutLineCountableConnection
+  addressValidator(input: AddressValidationInput!): AddressValidationData
   customers(query: String, before: String, after: String, first: Int, last: Int): UserCountableConnection
   staffUsers(query: String, before: String, after: String, first: Int, last: Int): UserCountableConnection
+  user(id: ID!): User
   node(id: ID!): Node
 }
 
diff --git a/saleor/product/migrations/0077_generate_versatile_background_images.py b/saleor/product/migrations/0077_generate_versatile_background_images.py
index df6d1e88c23..4ee8d9def58 100644
--- a/saleor/product/migrations/0077_generate_versatile_background_images.py
+++ b/saleor/product/migrations/0077_generate_versatile_background_images.py
@@ -4,8 +4,6 @@
 from django.db import migrations, models
 from versatileimagefield.image_warmer import VersatileImageFieldWarmer
 
-from saleor.product.models import Category, Collection
-
 
 def log_failed_images(failed_to_create):
     if failed_to_create:
@@ -24,10 +22,12 @@ def warm_model_background_images(model: models.Model):
     log_failed_images(failed_to_create)
 
 
-def warm_background_images(*_):
+def warm_background_images(apps, *_):
+    Category = apps.get_model('product', 'Category')
     print('Generating thumbnails for Categories', file=stderr)
     warm_model_background_images(Category)
 
+    Collection = apps.get_model('product', 'Collection')
     print('Generating thumbnails for Collections', file=stderr)
     warm_model_background_images(Collection)
 
diff --git a/saleor/product/migrations/0078_auto_20181119_0535.py b/saleor/product/migrations/0078_auto_20181119_0535.py
new file mode 100644
index 00000000000..08882e82e2e
--- /dev/null
+++ b/saleor/product/migrations/0078_auto_20181119_0535.py
@@ -0,0 +1,23 @@
+# Generated by Django 2.1.3 on 2018-11-19 11:35
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('product', '0077_generate_versatile_background_images'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='collection',
+            name='description',
+            field=models.TextField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='collectiontranslation',
+            name='description',
+            field=models.TextField(blank=True, null=True),
+        ),
+    ]
diff --git a/saleor/product/models.py b/saleor/product/models.py
index c2565306acf..6084f9b7e72 100644
--- a/saleor/product/models.py
+++ b/saleor/product/models.py
@@ -3,7 +3,6 @@
 
 from django.conf import settings
 from django.contrib.postgres.fields import HStoreField
-from django.core.exceptions import ValidationError
 from django.core.validators import MinValueValidator
 from django.db import models
 from django.db.models import Q
@@ -454,6 +453,7 @@ class Collection(SeoModel):
     background_image = VersatileImageField(
         upload_to='collection-backgrounds', blank=True, null=True)
     is_published = models.BooleanField(default=False)
+    description = models.TextField(blank=True, null=True)
 
     objects = CollectionQuerySet.as_manager()
     translated = TranslationProxy()
@@ -476,6 +476,7 @@ class CollectionTranslation(SeoModelTranslation):
         Collection, related_name='translations',
         on_delete=models.CASCADE)
     name = models.CharField(max_length=128)
+    description = models.TextField(blank=True, null=True)
 
     class Meta:
         unique_together = (('language_code', 'collection'),)
diff --git a/saleor/static/dashboard-next/types/globalTypes.ts b/saleor/static/dashboard-next/types/globalTypes.ts
index 83cf71b2199..d1b73c87d84 100644
--- a/saleor/static/dashboard-next/types/globalTypes.ts
+++ b/saleor/static/dashboard-next/types/globalTypes.ts
@@ -177,6 +177,7 @@ export interface CollectionCreateInput {
   isPublished?: boolean | null;
   name?: string | null;
   slug?: string | null;
+  description?: string | null;
   backgroundImage?: any | null;
   seo?: SeoInput | null;
   products?: (string | null)[] | null;
@@ -186,6 +187,7 @@ export interface CollectionInput {
   isPublished?: boolean | null;
   name?: string | null;
   slug?: string | null;
+  description?: string | null;
   backgroundImage?: any | null;
   seo?: SeoInput | null;
 }
diff --git a/templates/dashboard/collection/detail.html b/templates/dashboard/collection/detail.html
index ca7443b0b9f..771afcb1a84 100644
--- a/templates/dashboard/collection/detail.html
+++ b/templates/dashboard/collection/detail.html
@@ -54,6 +54,9 @@
             <div class="row">
               {{ form.products|materializecss }}
             </div>
+            <div class="row">
+              {{ form.description|materializecss }}
+            </div>
             <div class="row">
               {{ form.background_image|materializecss }}
             </div>
diff --git a/tests/api/test_collection.py b/tests/api/test_collection.py
index 4e424ba1c5e..365320678e0 100644
--- a/tests/api/test_collection.py
+++ b/tests/api/test_collection.py
@@ -20,6 +20,7 @@ def test_collections_query(
                         isPublished
                         name
                         slug
+                        description
                         products {
                             totalCount
                         }
@@ -38,6 +39,7 @@ def test_collections_query(
     assert collection_data['isPublished']
     assert collection_data['name'] == collection.name
     assert collection_data['slug'] == collection.slug
+    assert collection_data['description'] == collection.description
     assert collection_data['products']['totalCount'] == collection.products.count()
 
     # query all collections only as a staff user with proper permissions
@@ -52,12 +54,13 @@ def test_create_collection(
         monkeypatch, staff_api_client, product_list, permission_manage_products):
     query = """
         mutation createCollection(
-            $name: String!, $slug: String!, $products: [ID], $backgroundImage: Upload!, $isPublished: Boolean!) {
+            $name: String!, $slug: String!, $description: String, $products: [ID], $backgroundImage: Upload!, $isPublished: Boolean!) {
             collectionCreate(
-                input: {name: $name, slug: $slug, products: $products, backgroundImage: $backgroundImage, isPublished: $isPublished}) {
+                input: {name: $name, slug: $slug, description: $description, products: $products, backgroundImage: $backgroundImage, isPublished: $isPublished}) {
                 collection {
                     name
                     slug
+                    description
                     products {
                         totalCount
                     }
@@ -77,9 +80,11 @@ def test_create_collection(
     image_file, image_name = create_image()
     name = 'test-name'
     slug = 'test-slug'
+    description = 'test-description'
     variables = {
-        'name': name, 'slug': slug, 'products': product_ids,
-        'backgroundImage': image_name, 'isPublished': True}
+        'name': name, 'slug': slug, 'description': description,
+        'products': product_ids, 'backgroundImage': image_name,
+        'isPublished': True}
     body = get_multipart_request_body(query, variables, image_file, image_name)
     response = staff_api_client.post_multipart(
         body, permissions=[permission_manage_products])
@@ -87,6 +92,7 @@ def test_create_collection(
     data = content['data']['collectionCreate']['collection']
     assert data['name'] == name
     assert data['slug'] == slug
+    assert data['description'] == description
     assert data['products']['totalCount'] == len(product_ids)
     collection = Collection.objects.get(slug=slug)
     assert collection.background_image.file
@@ -126,12 +132,13 @@ def test_update_collection(
         monkeypatch, staff_api_client, collection, permission_manage_products):
     query = """
         mutation updateCollection(
-            $name: String!, $slug: String!, $id: ID!, $isPublished: Boolean!) {
+            $name: String!, $slug: String!, $description: String, $id: ID!, $isPublished: Boolean!) {
             collectionUpdate(
-                id: $id, input: {name: $name, slug: $slug, isPublished: $isPublished}) {
+                id: $id, input: {name: $name, slug: $slug, description: $description, isPublished: $isPublished}) {
                 collection {
                     name
                     slug
+                    description
                 }
             }
         }
@@ -145,8 +152,9 @@ def test_update_collection(
 
     name = 'new-name'
     slug = 'new-slug'
+    description = 'new-description'
     variables = {
-        'name': name, 'slug': slug,
+        'name': name, 'slug': slug, 'description': description,
         'id': to_global_id('Collection', collection.id), 'isPublished': True}
     response = staff_api_client.post_graphql(
         query, variables, permissions=[permission_manage_products])
diff --git a/tests/conftest.py b/tests/conftest.py
index c151d184572..491ed17b250 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -593,7 +593,8 @@ def permission_manage_pages():
 @pytest.fixture
 def collection(db):
     collection = Collection.objects.create(
-        name='Collection', slug='collection', is_published=True)
+        name='Collection', slug='collection', is_published=True,
+        description='Test description')
     return collection
 
 


Code review comments:
diff_hunk: @@ -454,6 +453,7 @@ class Collection(SeoModel):
     background_image = VersatileImageField(
         upload_to='collection-backgrounds', blank=True, null=True)
     is_published = models.BooleanField(default=False)
+    description = models.TextField(blank=True, null=True)
path: saleor/product/models.py
text: @user1:
In the Category model, this field is not nullable (while it allows for blank values). Can we keep consistency and have the same signature here? This affects `schema.graphql` so we'd have to regenerate it. For migrations, we could just change the `0078_auto_20181119_0535.py` accordingly.