OBJECT
+ArchiveAccount
+Archive account and revoke refresh tokens.
+User must be verified and confirm password.
++ + link + + GraphQL Schema definition +
+- type ArchiveAccount {
- : Boolean
- : ExpectedErrorType
- }
+ ![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+
|---|
OBJECT
+Archive account and revoke refresh tokens.
+User must be verified and confirm password.
+- type ArchiveAccount {
- : Boolean
- : ExpectedErrorType
- }
+ SCALAR
+The Boolean scalar type represents true or false.
- scalar Boolean
+ OBJECT
+GraphQL Business Trips aggregated by month or year
+INPUT_OBJECT
+GraphQL Input type for Business trips
+- input BusinessTripInput {
- # Date
- : Date!
- # Transportation mode
- : String!
- # Start address
- : String
- # Destination address
- : String
- # Distance [meter]
- : Float
- # Size of the vehicle
- : String
- # Fuel type of the vehicle
- : String
- # Occupancy
- : Float
- # Seating class in plane
- : Int
- # Number of passengers
- : Int
- # Roundtrip [True/False]
- : Boolean
- }
+ ENUM
+An enumeration.
+- enum TransportationMode {
- # Car
- # Bus
- # Train
- # Plane
- }
+ OBJECT
+GraphQL Business Trip Type
+- type BusinessTripType {
- : ID!
- : UserType!
- : WorkingGroupType
- : Date!
- : Float!
- : Float!
- : TransportationMode!
- : String!
- }
+ OBJECT
+GraphQL Commuting aggregated by month or year
+INPUT_OBJECT
+GraphQL Input type for commuting
+
+ ENUM
+An enumeration.
+- enum TransportationMode {
- # Car
- # Bus
- # Train
- # Bicycle
- # E-bike
- # Motorbike
- # Tram
- }
+ OBJECT
+GraphQL Commuting Type
+- type CommutingType {
- : ID!
- : UserType!
- : WorkingGroupType
- : Date!
- : Float!
- : Float!
- : TransportationMode!
- }
+ OBJECT
+GraphQL mutation for business trips
+- type CreateBusinessTrip {
- : Boolean
- : BusinessTripType
- }
+ OBJECT
+GraphQL mutation for commuting
+OBJECT
+GraphQL mutation for electricity
+- type CreateElectricity {
- : Boolean
- : ElectricityType
- }
+ OBJECT
+GraphQL mutation for heating
+- type CreateHeating {
- : Boolean
- : HeatingType
- }
+ OBJECT
+Mutation to create a new working group
+- type CreateWorkingGroup {
- : Boolean
- : WorkingGroupType
- }
+ INPUT_OBJECT
+GraphQL Input type for creating a new working group
+- input CreateWorkingGroupInput {
- # Name of the working group
- : String
- # Name of institution the working group belongs to
- : String!
- # City of institution the working group belongs to
- : String!
- # Country of institution the working group belongs to
- : String!
- # Research field of working group
- : String!
- # Research subfield of working group
- : String!
- # Number of employees of working group
- : Int!
- }
+ - scalar Date
+ SCALAR
+The DateTime scalar type represents a DateTime
+value as specified by
+iso8601.
- scalar DateTime
+ OBJECT
+Delete account permanently or make user.is_active=False.
The behavior is defined on settings. +Anyway user refresh tokens are revoked.
+User must be verified and confirm password.
+- type DeleteAccount {
- : Boolean
- : ExpectedErrorType
- }
+ OBJECT
+A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document.
+In some cases, you need to provide options to alter GraphQL's execution behavior in ways field arguments will not suffice, such as conditionally including or skipping a field. Directives provide this by describing additional information to the executor.
+- type __Directive {
- : String!
- : String
- : [__DirectiveLocation!]!
- : [__InputValue!]!
- : Boolean! @deprecated( reason: "Use `locations`." )
- : Boolean! @deprecated( reason: "Use `locations`." )
- : Boolean! @deprecated( reason: "Use `locations`." )
- }
+ ENUM
+A Directive can be adjacent to many parts of the GraphQL language, a __DirectiveLocation describes one such possible adjacencies.
+- enum __DirectiveLocation {
- # Location adjacent to a query operation.
- # Location adjacent to a mutation operation.
- # Location adjacent to a subscription operation.
- # Location adjacent to a field.
- # Location adjacent to a fragment definition.
- # Location adjacent to a fragment spread.
- # Location adjacent to an inline fragment.
- # Location adjacent to a schema definition.
- # Location adjacent to a scalar definition.
- # Location adjacent to an object definition.
- # Location adjacent to a field definition.
- # Location adjacent to an argument definition.
- # Location adjacent to an interface definition.
- # Location adjacent to a union definition.
- # Location adjacent to an enum definition.
- # Location adjacent to an enum value definition.
- # Location adjacent to an input object definition.
- # Location adjacent to an input object field definition.
- }
+ OBJECT
+GraphQL Electricity aggregated by month or year
+ENUM
+An enumeration.
+- enum ElectricityFuelType {
- # German energy mix
- # Solar
- }
+ INPUT_OBJECT
+GraphQL Input type for electricity
+OBJECT
+GraphQL Electricity Type
+- type ElectricityType {
- : ID!
- : WorkingGroupType!
- : Float!
- : Date!
- : String!
- : Float!
- : ElectricityFuelType!
- : Float!
- : Float!
- }
+ OBJECT
+One possible value for a given Enum. Enum values are unique values, not a placeholder for a string or numeric value. However an Enum value is returned in a JSON response as a string.
+SCALAR
+Errors messages and codes mapped to
+fields or non fields errors.
+Example:
+{
+ field_name: [
+ {
+ "message": "error message",
+ "code": "error_code"
+ }
+ ],
+ other_field: [
+ {
+ "message": "error message",
+ "code": "error_code"
+ }
+ ],
+ nonFieldErrors: [
+ {
+ "message": "error message",
+ "code": "error_code"
+ }
+ ]
+}
+- scalar ExpectedErrorType
+ OBJECT
+Object and Interface types are described by a list of Fields, each of which has a name, potentially a list of arguments, and a return type.
+SCALAR
+The Float scalar type represents signed double-precision fractional values as specified by IEEE 754.
- scalar Float
+ SCALAR
+The GenericScalar scalar type represents a generic
+GraphQL scalar value that could be:
+String, Boolean, Int, Float, List or Object.
- scalar GenericScalar
+ OBJECT
+GraphQL Heating aggregated by month or year
+ENUM
+An enumeration.
+- enum HeatingFuelType {
- # Heat pump air
- # Heat pump ground
- # Heat pump water
- # Liquid gas
- # Oil
- # Pellets
- # Solar
- # Woodchips
- # Electricity
- # Gas
- # Coal
- # District heating
- }
+ INPUT_OBJECT
+GraphQL Input type for heating
+OBJECT
+GraphQL Heating Type
+- type HeatingType {
- : ID!
- : WorkingGroupType!
- : Float!
- : Date!
- : String!
- : Float!
- : HeatingFuelType!
- : HeatingUnit!
- : Float!
- : Float!
- }
+ ENUM
+An enumeration.
+- enum HeatingUnit {
- # kwh
- # kg
- # l
- # m^3
- }
+ SCALAR
+The ID scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as "4") or integer (such as 4) input value will be accepted as an ID.
- scalar ID
+ Directs the executor to include this field or fragment only when the if argument is true.
OBJECT
+Arguments provided to Fields or Directives and the input fields of an InputObject are represented as Input Values which describe their type and optionally a default value.
+OBJECT
+GraphQL Institution
+SCALAR
+The Int scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31 - 1) and 2^31 - 1 since represented in JSON as double-precision floating point numbers specifiedby IEEE 754.
- scalar Int
+ OBJECT
+GraphQL Mutations
+- type Mutation {
- # Register user with fields defined in the settings.
- #
- # If the email field of the user model is part of the
- # registration fields (default), check if there is
- # no user with that email or as a secondary email.
- #
- # If it exists, it does not register the user,
- # even if the email field is not defined as unique
- # (default of the default django user model).
- #
- # When creating the user, it also creates a `UserStatus`
- # related to that user, making it possible to track
- # if the user is archived, verified and has a secondary
- # email.
- #
- # Send account verification email.
- #
- # If allowed to not verified users login, return token.
- #
- # Arguments
- # email: [Not documented]
- # username: [Not documented]
- # password1: [Not documented]
- # password2: [Not documented]
- (
- : String!,
- : String!,
- : String!,
- : String!
- ): Register
- # Verify user account.
- #
- # Receive the token that was sent by email.
- # If the token is valid, make the user verified
- # by making the `user.status.verified` field true.
- #
- # Arguments
- # token: [Not documented]
- (: String!): VerifyAccount
- # Sends activation email.
- #
- # It is called resend because theoretically
- # the first activation email was sent when
- # the user registered.
- #
- # If there is no user with the requested email,
- # a successful response is returned.
- #
- # Arguments
- # email: [Not documented]
- (: String!): ResendActivationEmail
- # Send password reset email.
- #
- # For non verified users, send an activation
- # email instead.
- #
- # Accepts both primary and secondary email.
- #
- # If there is no user with the requested email,
- # a successful response is returned.
- #
- # Arguments
- # email: [Not documented]
- (: String!): SendPasswordResetEmail
- # Change user password without old password.
- #
- # Receive the token that was sent by email.
- #
- # If token and new passwords are valid, update
- # user password and in case of using refresh
- # tokens, revoke all of them.
- #
- # Also, if user has not been verified yet, verify it.
- #
- # Arguments
- # token: [Not documented]
- # newPassword1: [Not documented]
- # newPassword2: [Not documented]
- (
- : String!,
- : String!,
- : String!
- ): PasswordReset
- # Set user password - for passwordless registration
- #
- # Receive the token that was sent by email.
- #
- # If token and new passwords are valid, set
- # user password and in case of using refresh
- # tokens, revoke all of them.
- #
- # Also, if user has not been verified yet, verify it.
- #
- # Arguments
- # token: [Not documented]
- # newPassword1: [Not documented]
- # newPassword2: [Not documented]
- (: String!, : String!, : String!): PasswordSet
- # Archive account and revoke refresh tokens.
- #
- # User must be verified and confirm password.
- #
- # Arguments
- # password: [Not documented]
- (: String!): ArchiveAccount
- # Delete account permanently or make `user.is_active=False`.
- #
- # The behavior is defined on settings.
- # Anyway user refresh tokens are revoked.
- #
- # User must be verified and confirm password.
- #
- # Arguments
- # password: [Not documented]
- (: String!): DeleteAccount
- # Change account password when user knows the old password.
- #
- # A new token and refresh token are sent. User must be verified.
- #
- # Arguments
- # oldPassword: [Not documented]
- # newPassword1: [Not documented]
- # newPassword2: [Not documented]
- (
- : String!,
- : String!,
- : String!
- ): PasswordChange
- # Update user model fields, defined on settings.
- #
- # User must be verified.
- #
- # Arguments
- # firstName: [Not documented]
- # lastName: [Not documented]
- # isRepresentative: [Not documented]
- (
- : String,
- : String,
- : String
- ): UpdateAccount
- # Send activation to secondary email.
- #
- # User must be verified and confirm password.
- #
- # Arguments
- # email: [Not documented]
- # password: [Not documented]
- (
- : String!,
- : String!
- ): SendSecondaryEmailActivation
- # Verify user secondary email.
- #
- # Receive the token that was sent by email.
- # User is already verified when using this mutation.
- #
- # If the token is valid, add the secondary email
- # to `user.status.secondary_email` field.
- #
- # Note that until the secondary email is verified,
- # it has not been saved anywhere beyond the token,
- # so it can still be used to create a new account.
- # After being verified, it will no longer be available.
- #
- # Arguments
- # token: [Not documented]
- (: String!): VerifySecondaryEmail
- # Swap between primary and secondary emails.
- #
- # Require password confirmation.
- #
- # Arguments
- # password: [Not documented]
- (: String!): SwapEmails
- # Remove user secondary email.
- #
- # Require password confirmation.
- #
- # Arguments
- # password: [Not documented]
- (: String!): RemoveSecondaryEmail
- # Obtain JSON web token for given user.
- #
- # Allow to perform login with different fields,
- # and secondary email if set. The fields are
- # defined on settings.
- #
- # Not verified users can login by default. This
- # can be changes on settings.
- #
- # If user is archived, make it unarchive and
- # return `unarchiving=True` on output.
- #
- # Arguments
- # password: [Not documented]
- # email: [Not documented]
- # username: [Not documented]
- (: String!, : String, : String): ObtainJSONWebToken
- # Same as `grapgql_jwt` implementation, with standard output.
- #
- # Arguments
- # token: [Not documented]
- (: String!): VerifyToken
- # Same as `grapgql_jwt` implementation, with standard output.
- #
- # Arguments
- # refreshToken: [Not documented]
- (: String!): RefreshToken
- # Same as `grapgql_jwt` implementation, with standard output.
- #
- # Arguments
- # refreshToken: [Not documented]
- (: String!): RevokeToken
- # GraphQL mutation for business trips
- #
- # Arguments
- # input: [Not documented]
- (: BusinessTripInput!): CreateBusinessTrip
- # GraphQL mutation for electricity
- #
- # Arguments
- # input: [Not documented]
- (: ElectricityInput!): CreateElectricity
- # GraphQL mutation for heating
- #
- # Arguments
- # input: [Not documented]
- (: HeatingInput!): CreateHeating
- # GraphQL mutation for commuting
- #
- # Arguments
- # input: [Not documented]
- (: CommutingInput!): CreateCommuting
- # GraphQL mutation to set working group of user
- #
- # Arguments
- # input: [Not documented]
- (: WorkingGroupInput): SetWorkingGroup
- # Mutation to create a new working group
- #
- # Arguments
- # input: [Not documented]
- (: CreateWorkingGroupInput): CreateWorkingGroup
- }
+ INTERFACE
+An object with an ID
+OBJECT
+Obtain JSON web token for given user.
+Allow to perform login with different fields, +and secondary email if set. The fields are +defined on settings.
+Not verified users can login by default. This +can be changes on settings.
+If user is archived, make it unarchive and
+return unarchiving=True on output.
OBJECT
+The Relay compliant PageInfo type, containing data necessary to paginate this connection.
+ OBJECT
+Change account password when user knows the old password.
+A new token and refresh token are sent. User must be verified.
+- type PasswordChange {
- : Boolean
- : ExpectedErrorType
- : String
- : String
- }
+ OBJECT
+Change user password without old password.
+Receive the token that was sent by email.
+If token and new passwords are valid, update +user password and in case of using refresh +tokens, revoke all of them.
+Also, if user has not been verified yet, verify it.
+- type PasswordReset {
- : Boolean
- : ExpectedErrorType
- }
+ OBJECT
+Set user password - for passwordless registration
+Receive the token that was sent by email.
+If token and new passwords are valid, set +user password and in case of using refresh +tokens, revoke all of them.
+Also, if user has not been verified yet, verify it.
+- type PasswordSet {
- : Boolean
- : ExpectedErrorType
- }
+ OBJECT
+GraphQL Queries
+- type Query {
- : UserNode
- # The ID of the object
- #
- # Arguments
- # id: [Not documented]
- (: ID!): UserNode
- # Arguments
- # offset: [Not documented]
- # before: [Not documented]
- # after: [Not documented]
- # first: [Not documented]
- # last: [Not documented]
- # email: [Not documented]
- # username: [Not documented]
- # username_Icontains: [Not documented]
- # username_Istartswith: [Not documented]
- # isActive: [Not documented]
- # status_Archived: [Not documented]
- # status_Verified: [Not documented]
- # status_SecondaryEmail: [Not documented]
- (
- : Int,
- : String,
- : String,
- : Int,
- : Int,
- : String,
- : String,
- : String,
- : String,
- : Boolean,
- : Boolean,
- : Boolean,
- : String
- ): UserNodeConnection
- : [BusinessTripType]
- : [ElectricityType]
- : [HeatingType]
- : [CommutingType]
- : [WorkingGroupType]
- : [ResearchFieldType]
- : [InstitutionType]
- # Arguments
- # level: Aggregation level: group or institution. Default: group
- # timeInterval: Time interval for aggregation (month or year)
- (: String, : String): [HeatingAggregated]
- # Arguments
- # level: Aggregation level: group or institution. Default: group
- # timeInterval: Time interval for aggregation (month or year)
- (: String, : String): [ElectricityAggregated]
- # Arguments
- # level: Aggregation level: personal, group or institution.
- # Default: group
- # timeInterval: Time interval for aggregation (month or year)
- (: String, : String): [BusinessTripAggregated]
- # Arguments
- # level: Aggregation level: personal, group or institution.
- # Default: group
- # timeInterval: Time interval for aggregation (month or year)
- (: String, : String): [CommutingAggregated]
- # Arguments
- # start: Start date for calculation of total emissions
- # end: End date for calculation of total emissions
- # level: Aggregate by 'group' or 'institution'. Default: 'group')
- (: Date, : Date, : String): [TotalEmission]
- }
+ OBJECT
+Same as grapgql_jwt implementation, with standard output.
- type RefreshToken {
- : String
- : GenericScalar
- : Boolean
- : ExpectedErrorType
- : String
- }
+ OBJECT
+Register user with fields defined in the settings.
+If the email field of the user model is part of the +registration fields (default), check if there is +no user with that email or as a secondary email.
+If it exists, it does not register the user, +even if the email field is not defined as unique +(default of the default django user model).
+When creating the user, it also creates a UserStatus
+related to that user, making it possible to track
+if the user is archived, verified and has a secondary
+email.
Send account verification email.
+If allowed to not verified users login, return token.
+- type Register {
- : Boolean
- : ExpectedErrorType
- }
+ OBJECT
+Remove user secondary email.
+Require password confirmation.
+- type RemoveSecondaryEmail {
- : Boolean
- : ExpectedErrorType
- }
+ OBJECT
+GraphQL Research Field
+- type ResearchFieldType {
- : ID!
- : String!
- : String!
- : [WorkingGroupType!]!
- }
+ OBJECT
+Sends activation email.
+It is called resend because theoretically +the first activation email was sent when +the user registered.
+If there is no user with the requested email, +a successful response is returned.
+- type ResendActivationEmail {
- : Boolean
- : ExpectedErrorType
- }
+ OBJECT
+Same as grapgql_jwt implementation, with standard output.
- type RevokeToken {
- : Int
- : Boolean
- : ExpectedErrorType
- }
+ OBJECT
+A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation and subscription operations.
+- type __Schema {
- # A list of all types supported by this server.
- : [__Type!]!
- # The type that query operations will be rooted at.
- : __Type!
- # If this server supports mutation, the type that mutation operations will be
- # rooted at.
- : __Type
- # If this server support subscription, the type that subscription operations will
- # be rooted at.
- : __Type
- # A list of all directives supported by this server.
- : [__Directive!]!
- }
+ OBJECT
+Send password reset email.
+For non verified users, send an activation +email instead.
+Accepts both primary and secondary email.
+If there is no user with the requested email, +a successful response is returned.
+- type SendPasswordResetEmail {
- : Boolean
- : ExpectedErrorType
- }
+ OBJECT
+Send activation to secondary email.
+User must be verified and confirm password.
+- type SendSecondaryEmailActivation {
- : Boolean
- : ExpectedErrorType
- }
+ OBJECT
+GraphQL mutation to set working group of user
+Directs the executor to skip this field or fragment when the if argument is true.
SCALAR
+The String scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.
- scalar String
+ OBJECT
+Swap between primary and secondary emails.
+Require password confirmation.
+- type SwapEmails {
- : Boolean
- : ExpectedErrorType
- }
+ OBJECT
+GraphQL total emissions
+OBJECT
+The fundamental unit of any GraphQL Schema is the type. There are many kinds of types in GraphQL as represented by the __TypeKind enum.
Depending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name and description, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union and Interface, provide the Object types possible at runtime. List and NonNull types compose other types.
+- type __Type {
- : __TypeKind!
- : String
- : String
- # Arguments
- # includeDeprecated: [Not documented]
- (: Boolean): [__Field!]
- : [__Type!]
- : [__Type!]
- # Arguments
- # includeDeprecated: [Not documented]
- (: Boolean): [__EnumValue!]
- : [__InputValue!]
- : __Type
- }
+ ENUM
+An enum describing what kind of type a given __Type is
- enum __TypeKind {
- # Indicates this type is a scalar.
- # Indicates this type is an object. `fields` and `interfaces` are valid fields.
- # Indicates this type is an interface. `fields` and `possibleTypes` are valid
- # fields.
- # Indicates this type is a union. `possibleTypes` is a valid field.
- # Indicates this type is an enum. `enumValues` is a valid field.
- # Indicates this type is an input object. `inputFields` is a valid field.
- # Indicates this type is a list. `ofType` is a valid field.
- # Indicates this type is a non-null. `ofType` is a valid field.
- }
+ OBJECT
+Update user model fields, defined on settings.
+User must be verified.
+- type UpdateAccount {
- : Boolean
- : ExpectedErrorType
- }
+ OBJECT
+- type UserNode implements Node {
- # The ID of the object.
- : ID!
- : DateTime
- # Designates whether the user can log into this admin site.
- : Boolean!
- # Designates whether this user should be treated as active. Unselect this instead
- # of deleting accounts.
- : Boolean!
- : DateTime!
- : String!
- : String!
- : String!
- : String!
- : WorkingGroupType
- : Boolean!
- : WorkingGroupType
- : [CommutingType!]!
- : [BusinessTripType!]!
- : Int
- : Boolean
- : Boolean
- : String
- }
+ OBJECT
+- type UserNodeConnection {
- # Pagination data for this connection.
- : PageInfo!
- # Contains the nodes in this connection.
- : [UserNodeEdge]!
- }
+ OBJECT
+A Relay edge containing a UserNode and its cursor.
OBJECT
+GraphQL User Type
+- type UserType {
- : ID!
- : DateTime
- # Designates whether the user can log into this admin site.
- : Boolean!
- # Designates whether this user should be treated as active. Unselect this instead
- # of deleting accounts.
- : Boolean!
- : DateTime!
- : String!
- : String!
- : String!
- : String!
- : WorkingGroupType
- : Boolean!
- : WorkingGroupType
- : [CommutingType!]!
- : [BusinessTripType!]!
- : String!
- # Designates that this user has all permissions without explicitly assigning them.
- : Boolean!
- }
+ OBJECT
+Verify user account.
+Receive the token that was sent by email.
+If the token is valid, make the user verified
+by making the user.status.verified field true.
- type VerifyAccount {
- : Boolean
- : ExpectedErrorType
- }
+ OBJECT
+Verify user secondary email.
+Receive the token that was sent by email. +User is already verified when using this mutation.
+If the token is valid, add the secondary email
+to user.status.secondary_email field.
Note that until the secondary email is verified, +it has not been saved anywhere beyond the token, +so it can still be used to create a new account. +After being verified, it will no longer be available.
+- type VerifySecondaryEmail {
- : Boolean
- : ExpectedErrorType
- }
+ OBJECT
+Same as grapgql_jwt implementation, with standard output.
- type VerifyToken {
- : GenericScalar
- : Boolean
- : ExpectedErrorType
- }
+ INPUT_OBJECT
+GraphQL Input type for setting working group
+OBJECT
+GraphQL Working Group Type
+- type WorkingGroupType {
- : ID!
- : String!
- : InstitutionType
- : UserType
- : Int
- : ResearchFieldType!
- : [UserType!]!
- : [CommutingType!]!
- : [BusinessTripType!]!
- : [HeatingType!]!
- : [ElectricityType!]!
- }
+ Hello lisalou!
Please activate your account on the link:
http://localhost:8000/activate/eyJlbWFpbCI6Imxpc2Fsb3VAdW5pLWhkLmRlIiwiYWN0aW9uIjoiYWN0aXZhdGlvbiJ9:1mCmEp:eBGetW65MtzO5f9LJAIhFKHjhTcwEeS1Ys2sxUgMWIQ
-The token in the activation url is needed to verify the account. +The token in the activation url is needed to verify the account. #### request @@ -93,12 +101,12 @@ mutation { ### 3. Log in User -Required info from user: +Required info from user: -* email +* email * password -#### request +#### Request ``` mutation { @@ -111,7 +119,6 @@ mutation { token refreshToken user { - username firstName email isRepresentative @@ -131,7 +138,6 @@ mutation { "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6Imxpc2Fsb3VAdW5pLWhkLmRlIiwiZXhwIjoxNjI4NDQzNjc2LCJvcmlnSWF0IjoxNjI4NDQzMzc2fQ.SyQFNdccgxPnmMPtTmTKcOsNrhSlcdPVKOkyc-jjcm0", "refreshToken": "6a548eb3aacc5886dd366d9e419ee4aad08aa9fc", "user": { - "username": "lisalou", "firstName": "", "email": "lisalou@uni-hd.de", "isRepresentative": false @@ -141,14 +147,24 @@ mutation { } ``` -### 4. Update account +### 4. Update account + +User account needs to be verified firist. -User needs to be verified to update account data. Send requests using Postman so that token can be passed in header. +**Requres authentication by sending token in header** -See [documentation](https://django-graphql-auth.readthedocs.io/en/latest/api/#updateaccount) for more details. +See [documentation](https://django-graphql-auth.readthedocs.io/en/latest/api/#updateaccount) for more details. #### Graphql Query +**Header** + +``` +header = {"Content-Type": "application/json", "Authorization": f"JWT {TOKEN}"} +``` + +**Request** + ``` mutation { updateAccount ( @@ -160,7 +176,7 @@ mutation { } ``` -#### response +#### Response ``` { @@ -176,10 +192,20 @@ mutation { ### 5. Password reset -See [documentation](https://django-graphql-auth.readthedocs.io/en/latest/api/#passwordreset) for more details. +See [documentation](https://django-graphql-auth.readthedocs.io/en/latest/api/#passwordreset) for more details. +**Requres authentication by sending token in header** + #### Graphql Query +**Header** + +``` +header = {"Content-Type": "application/json", "Authorization": f"JWT {TOKEN}"} +``` + +**Request** + ``` mutation { passwordReset( @@ -193,7 +219,7 @@ mutation { } ``` -#### response +#### response ``` { @@ -206,9 +232,9 @@ mutation { } ``` -### 6. Resend activation email +### 6. Resend activation email -See [documentation](https://django-graphql-auth.readthedocs.io/en/latest/api/#resendactivationemail) for more details. +See [documentation](https://django-graphql-auth.readthedocs.io/en/latest/api/#resendactivationemail) for more details. #### Graphql Query @@ -224,7 +250,7 @@ mutation { } ``` -#### response +#### response ``` { @@ -237,11 +263,12 @@ mutation { } ``` -### 7. Send password reset email +### 7. Send password reset email Send password reset email. For non verified users, send an activation email instead. Accepts both primary and secondary email. If there is no user with the requested email, a successful response is returned. -See [documentation](https://django-graphql-auth.readthedocs.io/en/latest/api/#sendpasswordresetemail) for more details. +See [documentation](https://django-graphql-auth.readthedocs.io/en/latest/api/#sendpasswordresetemail) for more details. + #### Graphql Query @@ -256,7 +283,7 @@ mutation { } ``` -#### response +#### response ``` { @@ -270,11 +297,11 @@ mutation { ``` -### 8. Send password reset email +### 8. Send password reset email Change account password when user knows the old password. A new token and refresh token are sent. User must be verified. -See [documentation](https://django-graphql-auth.readthedocs.io/en/latest/api/#passwordchange) for more details. +See [documentation](https://django-graphql-auth.readthedocs.io/en/latest/api/#passwordchange) for more details. #### Graphql Query @@ -293,7 +320,7 @@ mutation { } ``` -#### response +#### response ``` { @@ -313,32 +340,42 @@ mutation { ## Queries -#### Get current user with working group and institution info +#### Get current user info including info on working group, institution or research field (remove the attribtues which are not needed) ``` query { me { - username + email + firstName + lastName + isRepresentative + verified workingGroup { + id name - groupId + nEmployees institution { - name - instId + name + city + state + country + } + field { + field + subfield } } } } ``` -#### Get all users +#### Get all users ``` query { users { edges { node { - username email } } diff --git a/backend/docs/graphql/working_group_management.md b/backend/docs/graphql/working_group_management.md new file mode 100644 index 00000000..70109fa5 --- /dev/null +++ b/backend/docs/graphql/working_group_management.md @@ -0,0 +1,112 @@ +## API: Working group management + +After a user has registered they can/should join a working group, for which there are three scenarios: + +#### 1. The user's working group already exists in the app. + +If the user's working group already exist, they can choose it by selecting the country, city, institution (e.g. Heidelberg University) and the working group's name from several dropdown menus. Valid countries, cities and institutions can be queried using the [institutions endpoint](#list-institutions), existing working groups using the [workinggroups endpoint](#list-working-groups). The selected working can be sent to backend using the [setworkingroup endpint](#set-working-group). + +#### 2. The user's working group does not exist yet in the app + +If the user's working group does not exists yet, they can create it by providing + +- the working group's name +- the institution (incl. country and city) it belongs to (from predefined selection) +- research field (from predefined selection) +- number of employees. + +## API requests + +### List working groups + +``` +query { + workinggroups { + id + name + field { + field + subfield + } + } +} +``` + +### List institutions + +``` +query { + institutions { + id + name + city + country + } +} +``` + +### List research fields + +``` +query { + researchfields { + field + subfield + } +} +``` + + +### Set working group + +``` +mutation ($name: String!, $institution: String!, $city: String!, $country: String!){ + setWorkingGroup (input: { + name: $name + institution: $institution + city: $city + country: $country + } + ) { + ok + user { + email + workingGroup { + name + } + } + } +} +``` + +## Create Working group + +``` +query = """ + mutation { + createWorkingGroup (input: { + name: "Hydrology" + institution: "Heidelberg University" + city: "Heidelberg" + country: "Germany" + field: "Natural Sciences" + subfield: "Earth and related environmental sciences" + nEmployees: 5 + }) { + ok + workinggroup { + name + representative { + email + } + } + } + } +""" +headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user_representative_token}", +} +response = requests.post(GRAPHQL_URL, json={"query": query}, headers=headers) + +``` diff --git a/backend/docs/graphql_add_data_queries.md b/backend/docs/graphql_add_data_queries.md deleted file mode 100644 index 31a40547..00000000 --- a/backend/docs/graphql_add_data_queries.md +++ /dev/null @@ -1,163 +0,0 @@ -# GraphQL: Adding co2 data - - -After server is running open `localhost:8000/graphql` in the browser. - - - -## Electricity - -### Front-End Form: - -Electricity data should be entered for each month. - -| Name| Input Type | Options / Comment | -|-----|------------------|--------------------| -| Date | Dropdown Fields | 1 box for Year and 1 box for Month | -| Building | Text input field | | -| Group share | Float input field | min:0, max: 1 | -| Consumption (kWh) | Float input field | | -| Energy source | Dropdown field | Options: Coal,District Heating,Electricity,Gas, Heat pump (air), Heat pump (ground), Heat pump (water), Liquid gas, Oil, Solar, Wood (pellets), Wood (wood chips) | - - - -### Query: - -``` -mutation createElectricity { - createElectricity (input: { - group_id: "" - timestamp: "2020-10-01" - consumption: 3000 - fuelType: "solar" - building: "348" - groupShare: 1 - }) { - ok - electricity { - timestamp - consumption - building - fuelType - co2e - } - } -} -``` - - -## Heating - -### Front-End Form: - -| Name| Input Type | Options / Comment | -|-----|------------------|--------------------| -| Date | Dropdown Fields | 1 box for Year and 1 box for Month | -| Building | Text input field | | -| Consumption (kWh) | Float input field | | -| Unit | Dropdown field | Options: l, kg, kwh, m^3| -| Energy source | Dropdown field | Options: German energy mix, Solar | -| Group share | Float input field | min:0, max: 1 | - - -### Query: - -``` -mutation createHeating{ - createHeating (input: { - group_id: "" - building: "348" - timestamp: "2022-10-01" - consumption: 3000 - unit: "l" - fuelType: "oil" - groupShare: 1 - }) { - ok - heating { - timestamp - consumptionKwh - fuelType - co2e - } - } -} -``` - -## Business Trip - -### Front-End Form: - -| Name| Input Type | Options / Comment | -|-----|------------------|--------------------| -| Date | Date Field | with year, month, day | -| Transportation mode | Drop down | Options: Car, Train, Plane, Bus | -| Start | Text fields | 3 fields for address, city, country | -| Destination | Text fields | 3 fields for address, city, country | -| Distance | Float field | | -| Size | Dropdown | Options: small, medium, large, average (only for car and bus) | -| Fuel type | Dropdown | Options: gasoline, diesel (only for car and bus) | -| Occupancy | Int Field | 0 - 100 (only for bus) | -| Seating class | Dropdown | Options: "average", "Economy class", "Premium economy class", "Business class", "First class" (only for plane) | -| Passengers | Int Field | 1 - 9 (only for car) | -| Round trip | Check box | | - - -### Query: - - -``` -mutation createBusinesstrip { - createBusinesstrip (input: { - username: "KarenAnderson" - groupId: "573b7bec-e9fe-4505-bb41-2bf9a2769a80" - timestamp: "2020-01-01" - transportationMode: "car" - distance: 200 - size: "medium" - fuelType: "gasoline" - passengers: 1 - roundtrip: false - }) { - ok - } -} -``` - -## Commuting - -### Front-End Form: - -| Name| Input Type | Options / Comment | -|-----|------------------|--------------------| -| Date | Dropdown Fields | 1 box for Year and 1 box for Month | -| Building | Text input field | | -| Consumption (kWh) | Float input field | | -| Unit | Dropdown | Options: l, kg, kwh, m^3| -| Energy source | Dropdown field | German energy mix, Solar | -| Group share | Float input field | min:0, max: 1 | - - - -### Query - -``` -mutation createCommuting { - createCommuting (input: { - username: "KlausMayer" - distance: 30 - transportationMode: "car" - fuelType: "gasoline" - size: "medium" - fromTimestamp: "2017-01-01" - toTimestamp: "2017-06-01" - workweeks: 40 - passengers: 1 - }) { - ok - } -} -``` - -### Resources -[Sanatan, M.: Building a GraphQL API with Django](https://stackabuse.com/building-a-graphql-api-with-django/) \ No newline at end of file diff --git a/backend/docs/graphql_data_requests.md b/backend/docs/graphql_data_requests.md deleted file mode 100644 index 772d35ac..00000000 --- a/backend/docs/graphql_data_requests.md +++ /dev/null @@ -1,201 +0,0 @@ -# GraphQL: Data requests - -## Queries - -There are three types of queries to request monthly (default) or annual co2e data: - -- **heatingAggregated** -- **electricityAggregated** -- **businesstripAggregated** -- **commutingAggregated** -- **allAggregated** (not implemented yet) - -The **aggregation level** can be specified using the arguments - -- **username:** co2e on user level (only for businesstrips) -- **groupId:** co2e on working group level -- **instId:** co2e on institution level - -The co2e emission can be returned as - -- absolute emissions (`co2e`) -- emissions per capita (`co2eCap`) - -per - -- month (`time_interval="month"`) -- year (`time_interval="year"`) - -### Examples: - -##### All aggregated (not implemented yet) - -``` - "data": { - "businesstripAggregated": [ - { - "co2e": 3229, - "date": "2019-01-01" - }, - { - "co2e": 3608, - "date": "2019-02-01" - }, - { - "co2e": 3111, - "date": "2019-03-01" - }, - ], - "heatingAggregated": [ - ... - ] - } -} -``` - -#### Monthly absolute emissions of business trips of a user -**Request:** - -``` json -query { - businessTripAggregated (username:"KimKlaus", time_interval="month") { - co2e - date - } -} -``` - -**Response:** - -``` -{ - "data": { - "businesstripAggregated": [ - { - "co2e": 3229, - "date": "2019-01-01" - }, - { - "co2e": 3608, - "date": "2019-02-01" - }, - { - "co2e": 3111, - "date": "2019-03-01" - }, - ] - } -} -``` - -#### Monthly absolute emissions of heating consumption of a working group - -[How to get all group ids](./graphql_user_requests.md). - -**Request:** - -``` json -query { - heatingAggregated (groupId:"f6c2965c-539e-456c-8e99-41cea9be4168") { - co2e - date - } -} -``` - -**Response:** - -``` -{ - "data": { - "heatingAggregated": [ - { - "co2e": 188.04196799999846, - "date": "2019-01-01" - }, - { - "co2e": 186.1296767999985, - "date": "2019-02-01" - }, - { - "co2e": 221.6664215999982, - "date": "2019-03-01" - }, - ] - } -} -``` - -#### Monthly absolute and per capita emissions of electricity consumption of an institution - -**Request:** - -``` json -query { - electricityAggregated (groupId:"c7876b21-6166-443b-97e5-f7c5413de520", - timeInterval:"month") { - co2e - co2eCap - date - } -} -``` - -**Response:** - -``` -{ - "data": { - "electricityAggregate": [ - { - "co2e": 3521.1789287999713, - "co2eCap": 234.7452619199981, - "date": "2019-01-01" - }, - { - "co2e": 4669.278026399962, - "co2eCap": 311.28520175999745, - "date": "2019-02-01" - } - ] - } -} -``` - - -#### Monthly absolute and per capita emissions from commuting for a working group - -**Request:** - -``` -query { - commutingAggregated (groupId:"e0ee4c7f-f266-47e5-877f-15dd396d3a57", - timeInterval:"year") { - co2eCap - co2e - date - } -} -``` - -**Response:** - -``` -{ - "data": { - "commutingAggregated": [ - { - "co2eCap": 1.5799993939731014, - "co2e": 23.69999090959652, - "date": "2017-01-01" - }, - { - "co2eCap": 1.5799993939731014, - "co2e": 23.69999090959652, - "date": "2017-02-01" - } - ] - } -} -``` - diff --git a/backend/docs/graphql_errors.md b/backend/docs/graphql_errors.md deleted file mode 100644 index aeba5270..00000000 --- a/backend/docs/graphql_errors.md +++ /dev/null @@ -1,22 +0,0 @@ -# Errors - -### `Module not found` in backend container - -The backend container won't build correctly, because `Module not found django_extensions`. - -**Solution:** Delete all containerst and images. Then run `docker volume prune` to delete all data associated with them. - - -### Send Registration email failed - -When registering a new user, sending the activation email fails. - -**Solution:** Add the `EMAIL_FROM` variable to `settings.py` (see [Django-Graphql_Auth Docs](https://django-graphql-auth.readthedocs.io/en/latest/settings/)) - -``` -GRAPHQL_AUTH = { - 'LOGIN_ALLOWED_FIELDS': ['email', 'username'], - 'SEND_ACTIVATION_EMAIL': True, - 'EMAIL_FROM': 'no-reply@pledge4future.org', -} -``` diff --git a/backend/docs/img/database_structure.png b/backend/docs/img/database_structure.png new file mode 100644 index 00000000..da4ec93b Binary files /dev/null and b/backend/docs/img/database_structure.png differ diff --git a/backend/docs/postman/Pledge4Future.postman_collection.json b/backend/docs/postman/Pledge4Future.postman_collection.json deleted file mode 100644 index 5da36719..00000000 --- a/backend/docs/postman/Pledge4Future.postman_collection.json +++ /dev/null @@ -1,151 +0,0 @@ -{ - "info": { - "_postman_id": "46aea853-3195-420f-b922-25bc44c94b6e", - "name": "Pledge4Future", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" - }, - "item": [ - { - "name": "Register", - "request": { - "method": "POST", - "header": [ - { - "key": "token", - "value": "", - "type": "text" - } - ], - "body": { - "mode": "graphql", - "graphql": { - "query": "mutation {\n\tregister (\n email: \"test@pledge4future.org\",\n username: \"lisalou\",\n password1: \"lisa445566!\",\n password2: \"lisa445566!\"\n ) {\n\t success\n errors\n token\n refreshToken \n }\n}", - "variables": "" - } - }, - "url": { - "raw": "localhost:8000/graphql/", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "graphql", - "" - ] - } - }, - "response": [] - }, - { - "name": "Verify Account", - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "graphql", - "graphql": { - "query": "mutation {\n\tverifyAccount (\n token: \"eyJlbWFpbCI6InRlc3RAcGxlZGdlNGZ1dHVyZS5vcmciLCJhY3Rpb24iOiJhY3RpdmF0aW9uIn0:1mIEsJ:ARBmprFuNQ7jIpnzsW0tGUz36Vr6wQLukkRiMJMAU6c\"\n ) {\n\t\tsuccess\n errors\n }\n}", - "variables": "" - } - }, - "url": { - "raw": "localhost:8000/graphql/", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "graphql", - "" - ] - } - }, - "response": [] - }, - { - "name": "Login", - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "graphql", - "graphql": { - "query": "mutation {\n\ttokenAuth (\n email: \"test@pledge4future.org\"\n password: \"lisa445566!\"\n ) {\n\t success\n errors\n token\n refreshToken\n user {\n username\n firstName\n email\n isRepresentative\n }\n }\n}", - "variables": "" - } - }, - "url": { - "raw": "localhost:8000/graphql/", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "graphql", - "" - ] - } - }, - "response": [] - }, - { - "name": "RefreshToken", - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "graphql", - "graphql": { - "query": "mutation {\n tokenAuth(\n # username or email\n email: \"test@pledge4future.org\"\n password: \"lisa445566!\"\n ) {\n success,\n errors,\n token,\n refreshToken,\n unarchiving,\n user {\n id,\n username\n }\n }\n}", - "variables": "" - } - }, - "url": { - "raw": "localhost:8000/graphql/", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "graphql", - "" - ] - } - }, - "response": [] - }, - { - "name": "UpdateUser", - "request": { - "method": "POST", - "header": [ - { - "key": "Authorization", - "value": "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRlc3RAcGxlZGdlNGZ1dHVyZS5vcmciLCJleHAiOjE2Mjk3NDQzNzIsIm9yaWdJYXQiOjE2Mjk3NDQwNzJ9.EuostrVc_x1m2C8Aau3xleI1y7P72KMWi0wssjt3wM4", - "type": "text" - } - ], - "body": { - "mode": "graphql", - "graphql": { - "query": "mutation {\n\tupdateAccount (\n firstName: \"Louise\"\n ) {\n\tsuccess\n errors\n }\n}", - "variables": "" - } - }, - "url": { - "raw": "localhost:8000/graphql/", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "graphql", - "" - ] - } - }, - "response": [] - } - ] -} \ No newline at end of file diff --git a/backend/assets/requirements.txt b/backend/requirements.txt similarity index 75% rename from backend/assets/requirements.txt rename to backend/requirements.txt index 6a850582..db11ae1c 100644 --- a/backend/assets/requirements.txt +++ b/backend/requirements.txt @@ -5,7 +5,6 @@ chardet==4.0.0 Django==3.1.7 django-cors-headers==3.7.0 django-extensions==3.1.1 -django-cors-headers==3.7.0 django-filter==2.4.0 django-graphql-auth==0.3.16 django-graphql-jwt==0.3.0 @@ -16,14 +15,17 @@ graphene-django==2.15.0 graphql-core==2.3.2 graphql-relay==2.0.1 idna==2.10 -numpy==1.20.2 +numpy==1.23.0 openrouteservice==2.3.3 -pandas==1.2.3 +pandas==1.4.0 promise==2.3 psycopg2-binary==2.8.6 +pydot==1.4.2 PyJWT==1.7.1 +pyparsing==3.0.9 python-dateutil==2.8.1 python-dotenv==0.17.1 +python-Levenshtein==0.12.2 pytz==2021.1 requests==2.25.1 requests-toolbelt==0.9.1 @@ -32,4 +34,11 @@ singledispatch==3.6.1 six==1.15.0 sqlparse==0.4.1 text-unidecode==1.3 -urllib3==1.26.4 \ No newline at end of file +thefuzz==0.19.0 +urllib3==1.26.4 +whitenoise==6.2.0 +pydot==1.4.2 +gunicorn +iso3166 +pydantic==1.9.0 +python-Levenshtein diff --git a/backend/src/co2calculator b/backend/src/co2calculator index 1377c52b..761bb5c6 160000 --- a/backend/src/co2calculator +++ b/backend/src/co2calculator @@ -1 +1 @@ -Subproject commit 1377c52bf3d1f8a603aaa4a08603af8cc1f5b0b3 +Subproject commit 761bb5c61ac88470612e0dc30b3896d89d2c6837 diff --git a/backend/src/emissions/__init__.py b/backend/src/emissions/__init__.py index e69de29b..54510dbd 100644 --- a/backend/src/emissions/__init__.py +++ b/backend/src/emissions/__init__.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""init""" diff --git a/backend/src/emissions/admin.py b/backend/src/emissions/admin.py index b1cdccd1..86bff333 100644 --- a/backend/src/emissions/admin.py +++ b/backend/src/emissions/admin.py @@ -1,10 +1,33 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Admin settings""" + + from django.contrib import admin -from emissions.models import User, WorkingGroup, BusinessTrip, Heating, Electricity, Institution, Commuting, \ - CommutingGroup, BusinessTripGroup from django.apps import apps +from emissions.models import ( + CustomUser, + WorkingGroup, + Institution, + Heating, + Electricity, + Commuting, + CommutingGroup, + BusinessTrip, + BusinessTripGroup, + ResearchField, + WorkingGroupJoinRequest +) + +# Admin Models: Configure how information is displayed on Django Admin page + +class CustomUserAdmin(admin.ModelAdmin): + """Configures how CustomUser info is displayed""" + readonly_fields = ('is_representative', 'username', ) + -# Register your models here. -admin.site.register(User) +# Register your models here +admin.site.register(CustomUser, CustomUserAdmin) admin.site.register(WorkingGroup) admin.site.register(Institution) admin.site.register(Heating) @@ -13,8 +36,11 @@ admin.site.register(CommutingGroup) admin.site.register(BusinessTrip) admin.site.register(BusinessTripGroup) +admin.site.register(ResearchField) +admin.site.register(WorkingGroupJoinRequest) -app = apps.get_app_config('graphql_auth') +# GraphQL +app = apps.get_app_config("graphql_auth") -for model_name, model in app.models.items(): +for _, model in app.models.items(): admin.site.register(model) diff --git a/backend/src/emissions/apps.py b/backend/src/emissions/apps.py index 379f1db6..66b0f560 100644 --- a/backend/src/emissions/apps.py +++ b/backend/src/emissions/apps.py @@ -1,5 +1,14 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""apps""" + from django.apps import AppConfig class EmissionsConfig(AppConfig): - name = 'emissions' + """Config""" + + name = "emissions" + + def ready(self): + import emissions.signals diff --git a/backend/src/emissions/data/test_data.json b/backend/src/emissions/data/test_data.json new file mode 100644 index 00000000..4f78814f --- /dev/null +++ b/backend/src/emissions/data/test_data.json @@ -0,0 +1,106 @@ +{"users": { + "test_user1": { + "username": "test1", + "first_name": "test1", + "last_name": "user", + "email": "test1@pledge4future.org", + "password": "test_password" + }, + "test_user2": { + "username": "test2", + "first_name": "test2", + "last_name": "user", + "email": "test2@pledge4future.org", + "password": "test_password" + }, + "test_user3_representative": { + "username": "test3_rep", + "first_name": "test3_rep", + "last_name": "user", + "email": "test3@pledge4future.org", + "password": "test_password" + }, + "test_user4_representative": { + "username": "test4_rep", + "first_name": "test4_rep", + "last_name": "user", + "email": "test4@pledge4future.org", + "password": "test_password" + } +}, +"institutions": { + "institution1": { + "id": "59812cd8-9fcd-11ed-a8fc-0242ac120002", + "name": "Test University", + "city": "Heidelberg", + "country": "Germany" + } +}, +"working_groups": { + "working_group1": { + "id": "2f681772-9fcd-11ed-a8fc-0242ac120002", + "name": "testgroup1", + "institution": { + "id": "59812cd8-9fcd-11ed-a8fc-0242ac120002" + }, + "representative": "test3_rep", + "n_employees": 10, + "research_field": { + "id": 1 + }, + "is_public": "True" + }, + "working_group2": { + "id": "3aaa81a6-9fcd-11ed-a8fc-0242ac120002", + "name": "testgroup2", + "institution": { + "id": "59812cd8-9fcd-11ed-a8fc-0242ac120002" + }, + "representative": "test4_rep", + "n_employees": 10, + "research_field": { + "id": 2 + }, + "is_public": "False" + }, + "workinggroup_to_delete": { + "id": "4aaa81a6-9fcd-11ed-a8fc-0242ac120002", + "name": "workinggroup_to_delete", + "institution": { + "id": "59812cd8-9fcd-11ed-a8fc-0242ac120002" + }, + "representative": "test3_rep", + "n_employees": 10, + "research_field": { + "id": 2 + }, + "is_public": "False" + } +}, +"business_trips": { + "business_trip1": { + + } +}, +"heating": [ + { + "working_group_id": "2f681772-9fcd-11ed-a8fc-0242ac120002", + "consumption": 2000, + "unit": "l", + "fuel_type": "oil", + "building": "348", + "timestamp": "2022-01-01", + "group_share": 1 + } +], +"electricity": [ + { + "working_group_id": "2f681772-9fcd-11ed-a8fc-0242ac120002", + "consumption": 1000, + "fuel_type": "german_energy_mix", + "building": "348", + "timestamp": "2022-01-01", + "group_share": 1 + } +] +} \ No newline at end of file diff --git a/backend/src/emissions/decorators.py b/backend/src/emissions/decorators.py new file mode 100644 index 00000000..5f9ad01f --- /dev/null +++ b/backend/src/emissions/decorators.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Decorators to handle permissions""" + +from graphql import GraphQLError +from graphql_jwt.decorators import context +from functools import wraps +from typing import Callable + + +def representative_required(func: Callable): + """ + Decorator which checks whether a user is a group representative. If not raises GraphQLError. + :param func: + :type func: + :return: + :rtype: + """ + @wraps(func) + @context(func) + def wrapper_func(context, *args, **kwargs): + if context.user.is_representative: + return func(*args, **kwargs) + raise GraphQLError("Only group representatives have permission to perform this action.") + return wrapper_func diff --git a/backend/src/emissions/email_client.py b/backend/src/emissions/email_client.py new file mode 100644 index 00000000..65075077 --- /dev/null +++ b/backend/src/emissions/email_client.py @@ -0,0 +1,69 @@ +from pathlib import Path +from django.template.loader import render_to_string +from django.utils.html import strip_tags +from django.core.mail import EmailMessage + + +class EmailClient: + """Handles sending emails""" + + def __init__(self, template_dir: str): + """ + Initialize + :param template_dir: Path to directory containing templates + """ + self.template_dir = Path(template_dir) / 'email' + + def get_template_email(self, name: str, values: dict = None): + """ + Get template for email text from template folder + :param name: Name of template + :param values: Values which should be replaced in template text + :return: + """ + if not values: + values = {} + template_file = self.template_dir / (name + '_email.html') + if not template_file.exists(): + raise FileNotFoundError(f'{template_file} does not exist.') + html_message = render_to_string(template_file, values) + text_content = strip_tags(html_message) # Strip the html tag. So people can see the pure text at least. + return text_content, html_message + + def get_template_subject(self, name: str, values: dict = None): + """ + Get template for email subject from template folder + :param name: Name of template + :param values: Values which should be replaced in template subject text + :return: + """ + if not values: + values = {} + template_file = self.template_dir / (name + '_subject.txt') + if not template_file.exists(): + raise FileNotFoundError(f'{template_file} does not exist.') + subject = render_to_string(template_file, values) + return subject + + def send_email(self, subject: str, html_message: str, from_email: str, to_email: str): + """ + Sends email + :param subject: Subject text + :param text: Email text + :param html_message: Email text as html + :param from_email: Email address of sender + :param to_email: Email address of recipient + :return: + """ + mail = EmailMessage( + subject, + html_message, + from_email, + [to_email], + ) + mail.fail_silently = False + mail.content_subtype = 'html' + mail.send() + + + diff --git a/backend/src/emissions/fixtures/__init__.py b/backend/src/emissions/fixtures/__init__.py new file mode 100644 index 00000000..54510dbd --- /dev/null +++ b/backend/src/emissions/fixtures/__init__.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""init""" diff --git a/backend/src/emissions/fixtures/research_fields.json b/backend/src/emissions/fixtures/research_fields.json new file mode 100644 index 00000000..31345020 --- /dev/null +++ b/backend/src/emissions/fixtures/research_fields.json @@ -0,0 +1 @@ +[{"model": "emissions.researchfield", "pk": 1, "fields": {"field": "Natural Sciences", "subfield": "Mathematics", "id": 1}}, {"model": "emissions.researchfield", "pk": 2, "fields": {"field": "Natural Sciences", "subfield": "Computer and information sciences", "id": 2}}, {"model": "emissions.researchfield", "pk": 3, "fields": {"field": "Natural Sciences", "subfield": "Physical sciences", "id": 3}}, {"model": "emissions.researchfield", "pk": 4, "fields": {"field": "Natural Sciences", "subfield": "Chemical sciences", "id": 4}}, {"model": "emissions.researchfield", "pk": 5, "fields": {"field": "Natural Sciences", "subfield": "Earth and related environmental sciences", "id": 5}}, {"model": "emissions.researchfield", "pk": 6, "fields": {"field": "Natural Sciences", "subfield": "Biological sciences", "id": 6}}, {"model": "emissions.researchfield", "pk": 7, "fields": {"field": "Natural Sciences", "subfield": "Other natural sciences", "id": 7}}, {"model": "emissions.researchfield", "pk": 8, "fields": {"field": "Engineering and Technology", "subfield": "Civil engineering", "id": 8}}, {"model": "emissions.researchfield", "pk": 9, "fields": {"field": "Engineering and Technology", "subfield": "Electrical engineering, electronic engineering, information engineering", "id": 9}}, {"model": "emissions.researchfield", "pk": 10, "fields": {"field": "Engineering and Technology", "subfield": "Mechanical engineering", "id": 10}}, {"model": "emissions.researchfield", "pk": 11, "fields": {"field": "Engineering and Technology", "subfield": "Chemical engineering", "id": 11}}, {"model": "emissions.researchfield", "pk": 12, "fields": {"field": "Engineering and Technology", "subfield": "Materials engineering", "id": 12}}, {"model": "emissions.researchfield", "pk": 13, "fields": {"field": "Engineering and Technology", "subfield": "Medical engineering", "id": 13}}, {"model": "emissions.researchfield", "pk": 14, "fields": {"field": "Engineering and Technology", "subfield": "Environmental engineering", "id": 14}}, {"model": "emissions.researchfield", "pk": 15, "fields": {"field": "Engineering and Technology", "subfield": "Environmental biotechnology", "id": 15}}, {"model": "emissions.researchfield", "pk": 16, "fields": {"field": "Engineering and Technology", "subfield": "Industrial biotechnology", "id": 16}}, {"model": "emissions.researchfield", "pk": 17, "fields": {"field": "Engineering and Technology", "subfield": "Nano-technology", "id": 17}}, {"model": "emissions.researchfield", "pk": 18, "fields": {"field": "Engineering and Technology", "subfield": "Other engineering and technologies", "id": 18}}, {"model": "emissions.researchfield", "pk": 19, "fields": {"field": "Medical and Health Sciences", "subfield": "Basic medicine", "id": 19}}, {"model": "emissions.researchfield", "pk": 20, "fields": {"field": "Medical and Health Sciences", "subfield": "Clinical medicine", "id": 20}}, {"model": "emissions.researchfield", "pk": 21, "fields": {"field": "Medical and Health Sciences", "subfield": "Health sciences", "id": 21}}, {"model": "emissions.researchfield", "pk": 22, "fields": {"field": "Medical and Health Sciences", "subfield": "Health biotechnology", "id": 22}}, {"model": "emissions.researchfield", "pk": 23, "fields": {"field": "Medical and Health Sciences", "subfield": "Other medical sciences", "id": 23}}, {"model": "emissions.researchfield", "pk": 24, "fields": {"field": "Agricultural Sciences", "subfield": "Agriculture, forestry, and fisheries", "id": 24}}, {"model": "emissions.researchfield", "pk": 25, "fields": {"field": "Agricultural Sciences", "subfield": "Animal and diary science", "id": 25}}, {"model": "emissions.researchfield", "pk": 26, "fields": {"field": "Agricultural Sciences", "subfield": "Veterinary science", "id": 26}}, {"model": "emissions.researchfield", "pk": 27, "fields": {"field": "Agricultural Sciences", "subfield": "Agricultural biotechnology", "id": 27}}, {"model": "emissions.researchfield", "pk": 28, "fields": {"field": "Agricultural Sciences", "subfield": "Other agricultural sciences", "id": 28}}, {"model": "emissions.researchfield", "pk": 29, "fields": {"field": "Social Sciences", "subfield": "Psychology", "id": 29}}, {"model": "emissions.researchfield", "pk": 30, "fields": {"field": "Social Sciences", "subfield": "Economics and business", "id": 30}}, {"model": "emissions.researchfield", "pk": 31, "fields": {"field": "Social Sciences", "subfield": "Educational sciences", "id": 31}}, {"model": "emissions.researchfield", "pk": 32, "fields": {"field": "Social Sciences", "subfield": "Sociology", "id": 32}}, {"model": "emissions.researchfield", "pk": 33, "fields": {"field": "Social Sciences", "subfield": "Law", "id": 33}}, {"model": "emissions.researchfield", "pk": 34, "fields": {"field": "Social Sciences", "subfield": "Political science", "id": 34}}, {"model": "emissions.researchfield", "pk": 35, "fields": {"field": "Social Sciences", "subfield": "Social and economic geography", "id": 35}}, {"model": "emissions.researchfield", "pk": 36, "fields": {"field": "Social Sciences", "subfield": "Media and communications", "id": 36}}, {"model": "emissions.researchfield", "pk": 37, "fields": {"field": "Social Sciences", "subfield": "Other social sciences", "id": 37}}, {"model": "emissions.researchfield", "pk": 38, "fields": {"field": "Humanities", "subfield": "History and archeology", "id": 38}}, {"model": "emissions.researchfield", "pk": 39, "fields": {"field": "Humanities", "subfield": "Language and literature", "id": 39}}, {"model": "emissions.researchfield", "pk": 40, "fields": {"field": "Humanities", "subfield": "Philosophy, ethics and religion", "id": 40}}, {"model": "emissions.researchfield", "pk": 41, "fields": {"field": "Humanities", "subfield": "Art (arts, history of arts, performing arts, music)", "id": 41}}, {"model": "emissions.researchfield", "pk": 42, "fields": {"field": "Humanities", "subfield": "Other humanities", "id": 42}}] \ No newline at end of file diff --git a/backend/src/emissions/management/__init__.py b/backend/src/emissions/management/__init__.py index e69de29b..54510dbd 100644 --- a/backend/src/emissions/management/__init__.py +++ b/backend/src/emissions/management/__init__.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""init""" diff --git a/backend/src/emissions/management/commands/__init__.py b/backend/src/emissions/management/commands/__init__.py index e69de29b..54510dbd 100644 --- a/backend/src/emissions/management/commands/__init__.py +++ b/backend/src/emissions/management/commands/__init__.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""init""" diff --git a/backend/src/emissions/management/commands/create_groups.py b/backend/src/emissions/management/commands/create_groups.py deleted file mode 100644 index 6fb8870d..00000000 --- a/backend/src/emissions/management/commands/create_groups.py +++ /dev/null @@ -1,47 +0,0 @@ -""" -Create permission groups -Create permissions (read only) to models for a set of groups -""" - -from django.core.management.base import BaseCommand -from django.contrib.auth.models import Group -from django.contrib.auth.models import Permission -import logging - - -class Command(BaseCommand): - def __init__(self, *args, **kwargs): - super(Command, self).__init__(*args, **kwargs) - - help = "Creates default groups" - - def handle(self, *args, **options): - - group_researcher, created = Group.objects.get_or_create(name='Researcher') - PERMISSIONS = ["add", "change", "delete", "view"] - MODELS = ["business trip"] - if created: - for model in MODELS: - for permission in PERMISSIONS: - name = 'Can {} {}'.format(permission, model) - try: - model_add_perm = Permission.objects.get(name=name) - except Exception: - logging.warning("Permission not found with name '{}'.".format(name)) - continue - group_researcher.permissions.add(model_add_perm) - - group_representative, created = Group.objects.get_or_create(name='Representative') - PERMISSIONS = ["add", "change", "delete", "view"] - MODELS = ["heating", "electricity", "business trip"] - if created: - for model in MODELS: - for permission in PERMISSIONS: - name = 'Can {} {}'.format(permission, model) - #print("Creating {}".format(name)) - try: - model_add_perm = Permission.objects.get(name=name) - except Exception: - logging.warning("Permission not found with name '{}'.".format(name)) - continue - group_representative.permissions.add(model_add_perm) \ No newline at end of file diff --git a/backend/src/emissions/management/commands/create_test_data.py b/backend/src/emissions/management/commands/create_test_data.py new file mode 100644 index 00000000..46adcc41 --- /dev/null +++ b/backend/src/emissions/management/commands/create_test_data.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# create test users from JSON files +import json +import logging +import os + +from django.core.management.base import BaseCommand +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError + +from emissions.models import (CustomUser, WorkingGroup, Institution, ResearchField, Heating, Electricity) + +from co2calculator.co2calculator import (calc_co2_heating, calc_co2_electricity) + +logger = logging.basicConfig() +script_path = os.path.dirname(os.path.realpath(__file__)) + + +ADMIN_USERNAME = os.environ.get("ADMIN_USERNAME") +ADMIN_EMAIL = os.environ.get("ADMIN_EMAIL") +ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD") + + +class Command(BaseCommand): + """Base Command to populate data""" + + def __init__(self, *args, **kwargs): + """Init class""" + super(Command, self).__init__(*args, **kwargs) + + help = "Creates users for unit tests" + + def handle(self, *args, **options): + """Populate database""" + + # Create super user + try: + CustomUser.objects.create_superuser( + username=ADMIN_USERNAME, + first_name='admin', + last_name='admin', + email=ADMIN_EMAIL, + password=ADMIN_PASSWORD + ) + except IntegrityError: + pass + + config_path = f"{script_path}/../../data/test_data.json" + + with open(config_path) as source: + config_data = json.load(source) + + # Create test users + users = config_data["users"] + for user, user_data in users.items(): + try: + new_user = CustomUser( + username=user_data["username"], + first_name=user_data["first_name"], + last_name=user_data["last_name"], + email=user_data["email"], + ) + new_user.set_password(user_data["password"]) + new_user.save() + # Set user status to verified + status = new_user.status + setattr(status, "verified", True) + status.save(update_fields=["verified"]) + new_user.save() + except IntegrityError as e: + print(e) + except Exception as e: + print(e) + + # Create institutions + institutions = config_data["institutions"] + for institution, institution_data in institutions.items(): + try: + new_institution = Institution( + id=institution_data["id"], + name=institution_data["name"], + city=institution_data["city"], + country=institution_data["country"], + ) + new_institution.save() + + except IntegrityError as e: + print(e) + + # Create working groups + working_groups = config_data["working_groups"] + for working_group, workinggroup_data in working_groups.items(): + try: + representative_user = CustomUser.objects.get(username=workinggroup_data["representative"]) + working_group = WorkingGroup( + id=workinggroup_data['id'], + name=workinggroup_data["name"], + institution=Institution.objects.filter( + id=workinggroup_data["institution"]["id"])[0], + representative=representative_user, + n_employees=workinggroup_data["n_employees"], + field=ResearchField.objects.filter( + id=workinggroup_data["research_field"]["id"] + )[0], + #public=workinggroup_data["public"] + ) + working_group.save() + # Set a representative for the group + representative_user.is_representative = True + representative_user.working_group = working_group + representative_user.save() + except IntegrityError as e: + print(e) + except ValidationError as e: + print(e) + + # Create heating entries + print("Loading heating entries...") + heating_entries = config_data["heating"] + for data in heating_entries: + try: + working_group = WorkingGroup.objects.filter(id=data["working_group_id"])[0] + + # Calculate co2e + co2e = calc_co2_heating( + consumption=data["consumption"], + unit=data["unit"], + fuel_type=data["fuel_type"], + area_share=data["group_share"], + ) + co2e_cap = co2e / working_group.n_employees + + # Store in database + new_heating = Heating( + working_group=working_group, + timestamp=data["timestamp"], + consumption=data["consumption"], + fuel_type=data["fuel_type"], + building=data["building"], + group_share=data["group_share"], + co2e=co2e, + co2e_cap=co2e_cap + ) + new_heating.save() + except IntegrityError as e: + print(e) + + # Create heating entries + print("Loading electricity entries...") + entries = config_data["electricity"] + for data in entries: + try: + working_group = WorkingGroup.objects.filter(id=data["working_group_id"])[0] + + # Calculate co2e + co2e = calc_co2_electricity( + consumption=data["consumption"], + fuel_type=data["fuel_type"], + energy_share=data["group_share"], + ) + + co2e_cap = co2e / working_group.n_employees + + new_electricity = Electricity( + working_group=working_group, + timestamp=data["timestamp"], + consumption=data["consumption"], + fuel_type=data["fuel_type"], + building=data["building"], + group_share=data["group_share"], + co2e=co2e, + co2e_cap=co2e_cap, + ) + new_electricity.save() + + except IntegrityError as e: + print(e) + + + # Create business trips + # business_trips = config_data["business_trips"] + # for business_trip, businesstrip_data in business_trips.items(): + # try: + # transportation_mode = ..., + # start = ..., + # destination = ..., + # distance = ..., + # size = ..., + # fuel_type = ..., + # occupancy = ..., + # seating = ..., + # passengers = ..., + # roundtrip = ... \ No newline at end of file diff --git a/backend/src/emissions/management/commands/load_institutions.py b/backend/src/emissions/management/commands/load_institutions.py new file mode 100644 index 00000000..993079be --- /dev/null +++ b/backend/src/emissions/management/commands/load_institutions.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Polulate database with dummy data""" + +from django.core.management.base import BaseCommand +from django.db.utils import IntegrityError + + +import pandas as pd +import os +import logging + +from emissions.models import Institution + + +# Load settings from ./.env file +#load_dotenv(find_dotenv()) + +logger = logging.basicConfig() + +script_path = os.path.dirname(os.path.realpath(__file__)) + + +class Command(BaseCommand): + """Base Command to populate data""" + + def __init__(self, *args, **kwargs): + """Init class""" + super(Command, self).__init__(*args, **kwargs) + + help = "Seeds the database." + + def handle(self, *args, **options): + """Populate database""" + + # LOAD INSTITUTIONS - GERMAN ONLY RIGHT NOW -------------------------------------------------------- + print("Loading institutions ...") + grid = pd.read_csv(f"{script_path}/../../data/grid.csv") + grid = grid.loc[grid.Country == "Germany"] + for inst in grid.iterrows(): + try: + new_institution = Institution( + name=inst[1].Name, + city=inst[1].City, + state=inst[1].State, + country=inst[1].Country, + ) + new_institution.save() + except IntegrityError: + print("Institutions already loaded.") + break + del grid diff --git a/backend/src/emissions/management/commands/populate_data.py b/backend/src/emissions/management/commands/populate_data.py index dc3e854e..149daf6c 100644 --- a/backend/src/emissions/management/commands/populate_data.py +++ b/backend/src/emissions/management/commands/populate_data.py @@ -1,139 +1,179 @@ -""" -Create permission groups -Create permissions (read only) to models for a set of groups -""" +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Polulate database with dummy data""" from django.core.management.base import BaseCommand -from django.contrib.auth.models import Group from django.db.models import Sum from django.db.utils import IntegrityError -from emissions.models import User, WorkingGroup, BusinessTrip, Heating, Electricity, Institution, Commuting, CommutingGroup -from co2calculator.co2calculator import calc_co2_heating, calc_co2_electricity, calc_co2_commuting, calc_co2_businesstrip + import numpy as np import pandas as pd import os import logging -from django.contrib.auth.management.commands import createsuperuser -from co2calculator.co2calculator import CommutingTransportationMode, BusinessTripTransportationMode, HeatingFuel, ElectricityFuel -logger = logging.basicConfig() +from emissions.models import ( + CustomUser, + WorkingGroup, + BusinessTrip, + Heating, + Electricity, + Institution, + Commuting, + CommutingGroup, + ResearchField, +) + +from co2calculator.co2calculator.calculate import ( + calc_co2_heating, + calc_co2_electricity, + calc_co2_commuting, +) + +from co2calculator.co2calculator.constants import ( + TransportationMode, + HeatingFuel, + ElectricityFuel, +) + +# Load settings from ./.env file +#load_dotenv(find_dotenv()) +logger = logging.basicConfig() script_path = os.path.dirname(os.path.realpath(__file__)) class Command(BaseCommand): + """Base Command to populate data""" + def __init__(self, *args, **kwargs): + """Init class""" super(Command, self).__init__(*args, **kwargs) - help = 'Seeds the database.' + help = "Seeds the database." def handle(self, *args, **options): - - # Create super user - try: - User.objects.create_superuser("admin", 'admin@admin.com', 'adminpass') - except IntegrityError: - pass - - # LOAD INSTITUTIONS - GERMAN ONLY RIGHT NOW -------------------------------------------------------- - print("Loading institutions ...") - grid = pd.read_csv(f"{script_path}/../../data/grid.csv") - grid = grid.loc[grid.Country == "Germany"] - for inst in grid.iterrows(): - try: - new_institution = Institution(name=inst[1].Name, - city=inst[1].City, - state=inst[1].State, - country=inst[1].Country) - new_institution.save() - except IntegrityError: - print("Institutions already loaded.") - break - del grid + """Populate database""" # CREATE USERS -------------------------------------------------------- print("Loading users ...") user_data = pd.read_csv(f"{script_path}/../../data/users.csv") for usr in user_data.iterrows(): try: - new_user = User(username=usr[1].first_name + usr[1].last_name, - first_name=usr[1].first_name, - last_name=usr[1].last_name, - email=f"{usr[1].first_name}.{usr[1].last_name}@uni-hd.de", - password="password1234") + new_user = CustomUser( + first_name=usr[1].first_name, + last_name=usr[1].last_name, + email=f"{usr[1].first_name}.{usr[1].last_name}@uni-hd.de", + ) + new_user.set_password("test_password") + new_user.save() + status = new_user.status + setattr(status, "verified", True) + status.save(update_fields=["verified"]) new_user.save() except IntegrityError: print("Users already exist.") break # CREATE WORKING GROUPS -------------------------------------------------------- - environmental_search = WorkingGroup.objects.filter(name="Environmental Research Group") + environmental_search = WorkingGroup.objects.filter( + name="Environmental Research Group" + ) if len(environmental_search) == 0: - wg_environmental = WorkingGroup(name="Environmental Research Group", - institution=Institution.objects.filter(name="Heidelberg University", - city="Heidelberg", - country="Germany")[0], - representative=User.objects.get(username="LarsWiese"), - n_employees=20) + wg_environmental = WorkingGroup( + name="Environmental Research Group", + institution=Institution.objects.filter( + name="Heidelberg University", city="Heidelberg", country="Germany" + )[0], + representative=CustomUser.objects.get(email="Lars.Wiese@uni-hd.de"), + n_employees=20, + field=ResearchField.objects.filter( + field="Natural Sciences", + subfield="Earth and related environmental sciences", + )[0], + is_public=True + ) wg_environmental.save() + else: wg_environmental = environmental_search[0] biomed_search = WorkingGroup.objects.filter(name="Biomedical Research Group") if len(biomed_search) == 0: - wg_biomed = WorkingGroup(name="Biomedical Research Group", - institution=Institution.objects.filter(name="Heidelberg University", - city="Heidelberg", - country="Germany")[0], - representative=User.objects.get(username="KarenAnderson"), - n_employees=15) + testuser_representative = CustomUser.objects.get(email="test3@pledge4future.org") + wg_biomed = WorkingGroup( + name="Biomedical Research Group", + institution=Institution.objects.filter( + name="Heidelberg University", city="Heidelberg", country="Germany" + )[0], + representative=testuser_representative, + n_employees=15, + field=ResearchField.objects.filter( + field="Natural Sciences", subfield="Biological sciences" + )[0], + ) wg_biomed.save() + testuser_representative.is_representative = True + testuser_representative.working_group = wg_biomed + testuser_representative.save() else: wg_biomed = biomed_search[0] # Update working groups of users for usr in user_data.iterrows(): - user_found = User.objects.filter(username=usr[1].first_name + usr[1].last_name)[0] + user_found = CustomUser.objects.filter( + first_name=usr[1].first_name, last_name=usr[1].last_name + )[0] wg_search = WorkingGroup.objects.filter(name=usr[1].working_group) user_found.working_group = wg_search[0] user_found.save() del user_data # CREATE FAKE DATA - dates = np.arange(np.datetime64('2019-01'), - np.datetime64('2021-01'), - np.timedelta64(1, "M")).astype( 'datetime64[D]') + dates = np.arange( + np.datetime64("2019-01"), np.datetime64("2021-01"), np.timedelta64(1, "M") + ).astype("datetime64[D]") # CREATE ELECTRICITY OBJECTS -------------------------------------------------------- if len(Electricity.objects.all()) == 0: print("Loading electricity data ...") - consumptions = np.random.uniform(low=8000, high=12000, size=24).astype("int") + consumptions = np.random.uniform(low=8000, high=12000, size=24).astype( + "int" + ) for c, d in zip(consumptions, dates): co2e = calc_co2_electricity(c, "german_energy_mix") co2e_cap = co2e / wg_biomed.n_employees - new_electricity = Electricity(working_group=wg_biomed, - timestamp=str(d), - consumption=c, - fuel_type=ElectricityFuel.GERMAN_ENERGY_MIX, - building="348", - group_share=1, - co2e=co2e, - co2e_cap=co2e_cap) + new_electricity = Electricity( + working_group=wg_biomed, + timestamp=str(d), + consumption=c, + fuel_type=ElectricityFuel.GERMAN_ENERGY_MIX.name.lower(), + building="348", + group_share=1, + co2e=co2e, + co2e_cap=co2e_cap, + ) new_electricity.save() - consumptions = np.random.uniform(low=11000, high=15000, size=24).astype("int") + consumptions = np.random.uniform(low=11000, high=15000, size=24).astype( + "int" + ) for c, d in zip(consumptions, dates): - co2e = calc_co2_electricity(c, "german_energy_mix") + co2e = calc_co2_electricity( + consumption=c, fuel_type="german_energy_mix" + ) co2e_cap = co2e / wg_environmental.n_employees - new_electricity = Electricity(working_group=wg_environmental, - timestamp=str(d), - consumption=c, - fuel_type=ElectricityFuel.GERMAN_ENERGY_MIX, - building="348", - group_share=1, - co2e=co2e, - co2e_cap=co2e_cap) + new_electricity = Electricity( + working_group=wg_environmental, + timestamp=str(d), + consumption=c, + fuel_type=ElectricityFuel.GERMAN_ENERGY_MIX.name.lower(), + building="348", + group_share=1, + co2e=co2e, + co2e_cap=co2e_cap, + ) new_electricity.save() # CREATE HEATING OBJECTS -------------------------------------------------------- @@ -142,60 +182,73 @@ def handle(self, *args, **options): consumptions = np.random.uniform(low=1400, high=2200, size=24).astype("int") for c, d in zip(consumptions, dates): - co2e = calc_co2_heating(consumption=c, unit="l", fuel_type="oil", area_share=1) + co2e = calc_co2_heating( + consumption=c, unit="l", fuel_type="oil", area_share=1 + ) co2e_cap = co2e / wg_biomed.n_employees - new_heating = Heating(working_group=wg_biomed, - timestamp=str(d), - consumption=c, - fuel_type=HeatingFuel.OIL, - building="348", - group_share=1, - co2e=co2e, - co2e_cap=co2e_cap) + new_heating = Heating( + working_group=wg_biomed, + timestamp=str(d), + consumption=c, + fuel_type=HeatingFuel.OIL.name.lower(), + building="348", + group_share=1, + co2e=co2e, + co2e_cap=co2e_cap, + ) new_heating.save() consumptions = np.random.uniform(low=1000, high=1500, size=24).astype("int") for c, d in zip(consumptions, dates): - co2e = calc_co2_heating(c, "l", "oil", area_share=1) + co2e = calc_co2_heating( + consumption=c, unit="l", fuel_type="oil", area_share=1 + ) co2e_cap = co2e / wg_environmental.n_employees - new_heating = Heating(working_group=wg_environmental, - timestamp=str(d), - consumption=c, - fuel_type=HeatingFuel.OIL, - building="348", - group_share=1, - co2e=co2e, - co2e_cap=co2e_cap) + new_heating = Heating( + working_group=wg_environmental, + timestamp=str(d), + consumption=c, + fuel_type=HeatingFuel.OIL.name.lower(), + building="348", + group_share=1, + co2e=co2e, + co2e_cap=co2e_cap, + ) new_heating.save() # CREATE BUSINESS TRIPS -------------------------------------------------------- if len(BusinessTrip.objects.all()) == 0: print("Loading business trip data ...") - modes = [BusinessTripTransportationMode.PLANE, - BusinessTripTransportationMode.CAR, - BusinessTripTransportationMode.TRAIN, - BusinessTripTransportationMode.BUS] + modes = [ + TransportationMode.PLANE, + TransportationMode.CAR, + TransportationMode.TRAIN, + TransportationMode.BUS, + ] - dates = np.arange(np.datetime64('2019-01-15'), - np.datetime64('2021-01-15'), - np.timedelta64(30, "D")).astype('datetime64[D]') + dates = np.arange( + np.datetime64("2019-01-15"), + np.datetime64("2021-01-15"), + np.timedelta64(30, "D"), + ).astype("datetime64[D]") - for usr in User.objects.all(): - if usr.working_group is None: - continue + for usr in CustomUser.objects.all(): + # if usr.working_group is None: + # continue for d in dates: co2e = co2e_cap = float(np.random.randint(50, 1000, 1)) - new_trip = BusinessTrip(user=usr, - working_group=usr.working_group, - distance=np.random.randint(100, 10000, 1), - co2e=co2e, - timestamp=str(d), - transportation_mode=np.random.choice(modes, 1)[0].value) + new_trip = BusinessTrip( + user=usr, + working_group=usr.working_group, + distance=np.random.randint(100, 10000, 1), + co2e=co2e, + timestamp=str(d), + transportation_mode=np.random.choice(modes, 1), + ) new_trip.save() - if len(Commuting.objects.all()) == 0: print("Loading commuting data ...") @@ -203,105 +256,121 @@ def handle(self, *args, **options): WEEKS_PER_MONTH = 4.34524 WEEKS_PER_YEAR = 52.1429 - dates_2019 = np.arange(np.datetime64('2019-01', "M"), - np.datetime64('2020-01', "M"), - np.timedelta64(1, "M")).astype('datetime64[D]') - dates_2020 = np.arange(np.datetime64('2020-01', "M"), - np.datetime64('2021-01', "M"), - np.timedelta64(1, "M")).astype('datetime64[D]') - - for usr in User.objects.all(): - if usr.working_group is None: + dates_2019 = np.arange( + np.datetime64("2019-01", "M"), + np.datetime64("2020-01", "M"), + np.timedelta64(1, "M"), + ).astype("datetime64[D]") + dates_2020 = np.arange( + np.datetime64("2020-01", "M"), + np.datetime64("2021-01", "M"), + np.timedelta64(1, "M"), + ).astype("datetime64[D]") + #print(dates_2019) + + for usr in CustomUser.objects.all(): + if usr.is_superuser: continue distance = np.random.randint(0, 20, 1) transportation_mode = "bicycle" for d_2019 in range(len(dates_2019) - 1): - from_timestamp = dates_2019[d_2019] - to_timestamp = dates_2019[d_2019+1] + timestamp = dates_2019[d_2019] # calculate co2 - weekly_co2e = calc_co2_commuting(transportation_mode=transportation_mode, - weekly_distance=distance) + weekly_co2e = calc_co2_commuting( + transportation_mode=transportation_mode, + weekly_distance=distance, + ) # Calculate monthly co2 - monthly_co2e = WEEKS_PER_MONTH * (workweeks / WEEKS_PER_YEAR) * weekly_co2e - dates = np.arange(np.datetime64(from_timestamp, "M"), - np.datetime64(to_timestamp, "M") + np.timedelta64(1, 'M'), - np.timedelta64(1, "M")).astype('datetime64[D]') - for d in dates: - commuting_instance = Commuting(timestamp=str(d), - distance=distance, - transportation_mode=transportation_mode, - co2e=monthly_co2e, - user=usr, - working_group=usr.working_group) - commuting_instance.save() - - # Update emissions of working group for date and transportation mode - entries = Commuting.objects.filter(working_group=usr.working_group, - transportation_mode=transportation_mode, - timestamp=str(d)) - metrics = { - "co2e": Sum("co2e"), - "distance": Sum("distance") - } - group_data = entries.aggregate(**metrics) - - co2e_cap = group_data["co2e"] / usr.working_group.n_employees - commuting_group_instance = CommutingGroup(working_group=usr.working_group, - timestamp=str(d), - transportation_mode=transportation_mode, - n_employees=usr.working_group.n_employees, - co2e=group_data["co2e"], - co2e_cap=co2e_cap, - distance=group_data["distance"]) - commuting_group_instance.save() + monthly_co2e = ( + WEEKS_PER_MONTH * (workweeks / WEEKS_PER_YEAR) * weekly_co2e + ) + + commuting_instance = Commuting( + timestamp=str(timestamp), + distance=distance, + transportation_mode=transportation_mode, + co2e=monthly_co2e, + user=usr, + working_group=usr.working_group, + ) + commuting_instance.save() + + if usr.working_group is None: + continue + + # Update emissions of working group for date and transportation mode + entries = Commuting.objects.filter( + working_group=usr.working_group, + transportation_mode=transportation_mode, + timestamp=str(timestamp), + ) + metrics = {"co2e": Sum("co2e"), "distance": Sum("distance")} + group_data = entries.aggregate(**metrics) + + co2e_cap = group_data["co2e"] / usr.working_group.n_employees + commuting_group_instance = CommutingGroup( + working_group=usr.working_group, + timestamp=str(timestamp), + transportation_mode=transportation_mode, + n_employees=usr.working_group.n_employees, + co2e=group_data["co2e"], + co2e_cap=co2e_cap, + distance=group_data["distance"], + ) + commuting_group_instance.save() transportation_mode = "car" for d_2020 in range(len(dates_2020) - 1): - from_timestamp = dates_2020[d_2020] - to_timestamp = dates_2020[d_2020 + 1] + timestamp = dates_2020[d_2020] + # to_timestamp = dates_2020[d_2020 + 1] # calculate co2 - weekly_co2e = calc_co2_commuting(transportation_mode=transportation_mode, - weekly_distance=distance, - passengers=1, - size="medium", - fuel_type="gasoline") + weekly_co2e = calc_co2_commuting( + transportation_mode=transportation_mode, + weekly_distance=distance, + passengers=1, + size="medium", + fuel_type="gasoline", + ) # Calculate monthly co2 - monthly_co2e = WEEKS_PER_MONTH * (workweeks / WEEKS_PER_YEAR) * weekly_co2e - dates = np.arange(np.datetime64(from_timestamp, "M"), - np.datetime64(to_timestamp, "M") + np.timedelta64(1, 'M'), - np.timedelta64(1, "M")).astype('datetime64[D]') - for d in dates: - commuting_instance = Commuting(timestamp=str(d), - distance=distance, - transportation_mode=transportation_mode, - co2e=monthly_co2e, - user=usr, - working_group=usr.working_group) - commuting_instance.save() - - # Update emissions of working group for date and transportation mode - entries = Commuting.objects.filter(working_group=usr.working_group, - transportation_mode=transportation_mode, - timestamp=str(d)) - metrics = { - "co2e": Sum("co2e"), - "distance": Sum("distance") - } - group_data = entries.aggregate(**metrics) - - co2e_cap = group_data["co2e"] / usr.working_group.n_employees - commuting_group_instance = CommutingGroup(working_group=usr.working_group, - timestamp=str(d), - transportation_mode=transportation_mode, - n_employees=usr.working_group.n_employees, - co2e=group_data["co2e"], - co2e_cap=co2e_cap, - distance=group_data["distance"]) - commuting_group_instance.save() - - + monthly_co2e = ( + WEEKS_PER_MONTH * (workweeks / WEEKS_PER_YEAR) * weekly_co2e + ) + + commuting_instance = Commuting( + timestamp=str(timestamp), + distance=distance, + transportation_mode=transportation_mode, + co2e=monthly_co2e, + user=usr, + working_group=usr.working_group, + ) + commuting_instance.save() + + if usr.working_group is None: + continue + + # Update emissions of working group for date and transportation mode + entries = Commuting.objects.filter( + working_group=usr.working_group, + transportation_mode=transportation_mode, + timestamp=str(timestamp), + ) + metrics = {"co2e": Sum("co2e"), "distance": Sum("distance")} + group_data = entries.aggregate(**metrics) + + co2e_cap = group_data["co2e"] / usr.working_group.n_employees + commuting_group_instance = CommutingGroup( + working_group=usr.working_group, + timestamp=str(timestamp), + transportation_mode=transportation_mode, + n_employees=usr.working_group.n_employees, + co2e=group_data["co2e"], + co2e_cap=co2e_cap, + distance=group_data["distance"], + ) + commuting_group_instance.save() diff --git a/backend/src/wepledge/__init__.py b/backend/src/emissions/migrations/__init__.py similarity index 100% rename from backend/src/wepledge/__init__.py rename to backend/src/emissions/migrations/__init__.py diff --git a/backend/src/emissions/models.py b/backend/src/emissions/models.py deleted file mode 100644 index 253fa075..00000000 --- a/backend/src/emissions/models.py +++ /dev/null @@ -1,342 +0,0 @@ -import uuid - -from django.core.validators import MinValueValidator, MaxValueValidator -from django.db import models -from django.contrib.auth.models import AbstractUser -from django.db.models import Sum -from django.utils.translation import gettext_lazy as _ -from django.core.exceptions import ValidationError - -from co2calculator.co2calculator import CommutingTransportationMode, BusinessTripTransportationMode, HeatingFuel, \ - ElectricityFuel, Unit - - -class User(AbstractUser): - """ - Researcher. May be normal user or a group representative - """ - email = models.EmailField(blank=False, max_length=255, verbose_name="email", unique=True) - username = models.CharField(max_length=100, unique=True) - first_name = models.CharField(max_length=25, blank=True) - last_name = models.CharField(max_length=25, blank=True) - working_group = models.ForeignKey('WorkingGroup', on_delete=models.SET_NULL, null=True, blank=True) - is_representative = models.BooleanField(default=False) - - USERNAME_FIELD = 'email' - EMAIL_FIELD = "email" - REQUIRED_FIELDS = ['username'] - - def __str__(self): - return self.username - - -class Institution(models.Model): - """ - Top level research institution, e.g. Heidelberg University - """ - name = models.CharField(max_length=200, null=False, blank=False) - city = models.CharField(max_length=100, null=False, blank=False) - state = models.CharField(max_length=100, null=True) - country = models.CharField(max_length=100, null=False, blank=False) - inst_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - readonly_fields = ('inst_id',) - - class Meta: - unique_together = ("name", "city", "country") - - def __str__(self): - return f"{self.name}, {self.city}, {self.country}" - - -class WorkingGroup(models.Model): - """ - Working group - """ - name = models.CharField(max_length=200, blank=False) - institution = models.ForeignKey(Institution, on_delete=models.PROTECT, null=True) - representative = models.ForeignKey(User, on_delete=models.PROTECT, null=True) - n_employees = models.IntegerField(null=True, blank=True) - research_field = models.CharField(null=True, blank=True, max_length=200) - group_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - readonly_fields = ('group_id',) - - class Meta: - unique_together = ("name", "institution") - - def clean(self, *args, **kwargs): - """ - Validate that the representative of the working group is member of the working group - :param args: - :param kwargs: - :return: - """ - # add custom validation here - if (self.representative.working_group != self) and (self.representative.working_group is not None): - raise ValidationError(_('New representative is not a member of this working group.'), code='invalid') - super().clean(*args, **kwargs) - - def save(self, *args, **kwargs): - """ - Updates the user who is the representative of the working group - :param args: - :param kwargs: - :return: - """ - self.full_clean() - super(WorkingGroup, self).save(*args, **kwargs) - - def __str__(self): - return f"{self.name}, {self.institution.name}, {self.institution.city}, {self.institution.country}" - - -class CommutingGroup(models.Model): - """ - Monthly emissions from commuting per working group - """ - working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE, null=True) - timestamp = models.DateField(null=False) - n_employees = models.IntegerField(null=False) - transportation_mode = models.CharField(max_length=30) - distance = models.FloatField(null=True) - co2e = models.FloatField() - co2e_cap = models.FloatField() - - def __str__(self): - return f"{self.working_group.name}, {self.transportation_mode}, {self.timestamp}" - - -class Commuting(models.Model): - """ - CO2 emissions from commuting per month - """ - user = models.ForeignKey(User, on_delete=models.CASCADE) - working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE, null=True) - timestamp = models.DateField(null=False) - co2e = models.FloatField() - distance = models.FloatField() - transportation_choices = [(x.name, x.value) for x in CommutingTransportationMode] - transportation_mode = models.CharField(max_length=15, - choices=transportation_choices, - blank=False, - ) - - def __str__(self): - return f"{self.user.username}, {self.transportation_mode}, {self.timestamp}" - -class Heating(models.Model): - """ - Heating consumption per year - """ - working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE) - consumption_kwh = models.FloatField(null=False) - timestamp = models.DateField(null=False) - - PUMPAIR = 'PUMPAIR' - PUMPGROUND = 'PUMPGROUND' - PUMPWATER = 'PUMPWATER' - LIQUID = 'LIQUID' - OIL = 'OIL' - PELLETS = 'PELLETS' - SOLAR = 'SOLAR' - WOODCHIPS = 'WOODCHIPS' - ELECTRICITY = 'ELECTRICITY' - GAS = 'GAS' - fuel_type_choices = [(PUMPAIR, 'Pump air'), (PUMPGROUND, 'Pump ground'), (PUMPWATER, 'Pump water'), - (LIQUID, 'Liquid'), (OIL, 'Oil'), (PELLETS, 'Pellets'), (SOLAR, 'Solar'), - (WOODCHIPS, 'Woodchips'), - (ELECTRICITY, 'Electricity'), (GAS, 'Gas')] - fuel_type = models.CharField(max_length=20, choices=fuel_type_choices, blank=False) - co2e = models.DecimalField(max_digits=10, decimal_places=1) - - def __str__(self): - return f"{self.working_group.name}, {self.timestamp}" - - -class Electricity(models.Model): - """ - Electricity consumption per year - """ - working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE) - consumption_kwh = models.FloatField(null=False) - timestamp = models.DateField(null=False) - - GERMAN_ELECTRICITY_MIX = 'german energy mix' # must be same as in data of co2calculator - #GREEN_ENERGY = 'GREEN_ENERGY' - SOLAR = 'solar' - fuel_type_choices = [(GERMAN_ELECTRICITY_MIX, 'German Energy Mix'), - #(GREEN_ENERGY, 'Green energy'), - (SOLAR, 'Solar')] - fuel_type = models.CharField(max_length=30, choices=fuel_type_choices, blank=False) - - co2e = models.DecimalField(max_digits=10, decimal_places=1) - - def __str__(self): - return f"{self.working_group.name}, {self.timestamp}" - - -class BusinessTripGroup(models.Model): - """ - Monthly business trip emissions per working group - """ - working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE, null=True) - timestamp = models.DateField(null=False) - n_employees = models.IntegerField(null=False) - transportation_choices = [(x.name, x.value) for x in BusinessTripTransportationMode] - transportation_mode = models.CharField(max_length=10, - choices=transportation_choices, - blank=False, - ) - distance = models.FloatField() - co2e = models.FloatField() - co2e_cap = models.FloatField() - - class Meta: - unique_together = ("working_group", "timestamp", "transportation_mode") - - def __str__(self): - return f"{self.working_group.name}, {self.timestamp}, {self.transportation_mode}" - - -class BusinessTrip(models.Model): - """ - Business trip - """ - user = models.ForeignKey(User, on_delete=models.CASCADE) - working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE, null=True) - timestamp = models.DateField(null=False) - distance = models.FloatField() - co2e = models.FloatField() - transportation_choices = [(x.name, x.value) for x in BusinessTripTransportationMode] - transportation_mode = models.CharField(max_length=10, - choices=transportation_choices, - blank=False, - ) - range_category = models.CharField(max_length=50) - - def save(self, *args, **kwargs): - # Calculate monthly co2 - super(BusinessTrip, self).save(*args, **kwargs) - if self.working_group is None: - return - - year = self.timestamp[:4] - month = self.timestamp[5:7] - entries = BusinessTrip.objects.filter(working_group=self.working_group, - timestamp__year=year, - timestamp__month=month, - transportation_mode=self.transportation_mode) - metrics = { - "co2e": Sum("co2e"), - "distance": Sum("distance") - } - group_data = entries.aggregate(**metrics) - co2e_cap = group_data["co2e"] / self.working_group.n_employees - - try: - obj = BusinessTripGroup.objects.get(working_group=self.working_group, - timestamp="{0}-{1}-01".format(year, month), - transportation_mode=self.transportation_mode) - obj.n_employees = self.working_group.n_employees - obj.distance = group_data["distance"] - obj.co2e = group_data["co2e"] - obj.co2e_cap = co2e_cap - obj.save() - except BusinessTripGroup.DoesNotExist: - BusinessTripGroup( - working_group=self.working_group, - timestamp="{0}-{1}-01".format(year, month), - transportation_mode=self.transportation_mode, - n_employees=self.working_group.n_employees, - distance=group_data["distance"], - co2e=group_data["co2e"], - co2e_cap=co2e_cap).save() - - def delete(self): - # Calculate monthly co2 - super(BusinessTrip, self).delete() - entries = BusinessTrip.objects.filter(working_group=self.working_group, - timestamp__year=self.timestamp.year, - timestamp__month=self.timestamp.month, - transportation_mode=self.transportation_mode) - print(entries) - if len(entries) == 0: - co2e = 0 - co2e_cap = 0 - distance = 0 - else: - metrics = { - "co2e": Sum("co2e"), - "distance": Sum("distance") - } - group_data = entries.aggregate(**metrics) - co2e = group_data["co2e"] - distance = group_data["distance"] - co2e_cap = co2e / self.working_group.n_employees - - try: - obj = BusinessTripGroup.objects.get(working_group=self.working_group, - timestamp="{0}-{1}-01".format(self.timestamp.year, - self.timestamp.month), - transportation_mode=self.transportation_mode) - obj.n_employees = self.working_group.n_employees - obj.distance = distance - obj.co2e = co2e - obj.co2e_cap = co2e_cap - obj.save() - except BusinessTripGroup.DoesNotExist: - BusinessTripGroup( - working_group=self.working_group, - timestamp="{0}-{1}-01".format(self.timestamp.year, self.timestamp.month), - transportation_mode=self.transportation_mode, - n_employees=self.working_group.n_employees, - distance=distance, - co2e=co2e, - co2e_cap=co2e_cap).save() - - - def __str__(self): - return f"{self.user.username}, {self.timestamp}" - - -class Heating(models.Model): - """ - Heating consumption per year - """ - working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE) - consumption = models.FloatField(null=False, validators=[MinValueValidator(0.0)]) - timestamp = models.DateField(null=False) - building = models.CharField(null=False, max_length=30) - group_share = models.FloatField(null=False, validators=[MinValueValidator(0.0), MaxValueValidator(1.0)]) - fuel_type_choices = [(x.name, x.value) for x in HeatingFuel] - fuel_type = models.CharField(max_length=20, choices=fuel_type_choices, blank=False) - co2e = models.FloatField() - co2e_cap = models.FloatField() - - class Meta: - unique_together = ("working_group", "timestamp", "fuel_type", "building") - - def __str__(self): - return f"{self.working_group.name}, {self.timestamp}, {self.fuel_type}, {self.building}" - - -class Electricity(models.Model): - """ - Electricity consumption for a timestamp - """ - working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE) - consumption = models.FloatField(null=False) - timestamp = models.DateField(null=False) - building = models.CharField(null=False, max_length=30) - group_share = models.FloatField(null=False, validators=[MinValueValidator(0.0), MaxValueValidator(1.0)]) - fuel_type_choices = [(x.name, x.value) for x in ElectricityFuel] - fuel_type = models.CharField(max_length=40, choices=fuel_type_choices, blank=False) - - co2e = models.FloatField() - co2e_cap = models.FloatField() - - class Meta: - unique_together = ("working_group", "timestamp", "fuel_type", "building") - - def __str__(self): - return f"{self.working_group.name}, {self.timestamp}, {self.fuel_type}, {self.building}" - diff --git a/backend/src/emissions/models/__init__.py b/backend/src/emissions/models/__init__.py new file mode 100644 index 00000000..4978d134 --- /dev/null +++ b/backend/src/emissions/models/__init__.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" Django models for handling co2 emission data """ + +from .customUser import CustomUser +from .researchField import ResearchField +from .institution import Institution +from .workingGroup import WorkingGroup +from .businessTrip import (BusinessTrip, BusinessTripGroup) +from .commuting import (Commuting, CommutingGroup) +from .electricity import Electricity +from .heating import Heating +from .workingGroupJoinRequest import WorkingGroupJoinRequest \ No newline at end of file diff --git a/backend/src/emissions/models/businessTrip.py b/backend/src/emissions/models/businessTrip.py new file mode 100644 index 00000000..95dac6b7 --- /dev/null +++ b/backend/src/emissions/models/businessTrip.py @@ -0,0 +1,144 @@ + +from django.db import models +from django.db.models import Sum + + +from emissions.models import (CustomUser, WorkingGroup) + +from co2calculator.co2calculator.constants import TransportationMode + + +class BusinessTripGroup(models.Model): + """Monthly business trip emissions per working group""" + + working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE, null=True) + timestamp = models.DateField(null=False) + n_employees = models.IntegerField(null=False) + transportation_choices = [(x.name, x.value) for x in TransportationMode] + transportation_mode = models.CharField( + max_length=10, + choices=transportation_choices, + blank=False, + ) + distance = models.FloatField() + co2e = models.FloatField() + co2e_cap = models.FloatField() + + class Meta: + """Specifies which attributes must be unique together""" + + unique_together = ("working_group", "timestamp", "transportation_mode") + + def __str__(self): + return ( + f"{self.working_group.name}, {self.timestamp}, {self.transportation_mode}" + ) + + +class BusinessTrip(models.Model): + """Business trip of an employee""" + + user = models.ForeignKey(CustomUser, on_delete=models.CASCADE) + working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE, null=True) + timestamp = models.DateField(null=False) + distance = models.FloatField() + co2e = models.FloatField() + transportation_choices = [(x.name, x.value) for x in TransportationMode] + transportation_mode = models.CharField( + max_length=10, + choices=transportation_choices, + blank=False, + ) + range_category = models.CharField(max_length=50) + + def save(self, *args, **kwargs): + """Recalculate the emission of the respective working group when a user adds a business trip""" + # Calculate monthly co2 + super(BusinessTrip, self).save(*args, **kwargs) + if self.working_group is None: + return + + year = self.timestamp.year + month = self.timestamp.month + entries = BusinessTrip.objects.filter( + working_group=self.working_group, + timestamp__year=year, + timestamp__month=month, + transportation_mode=self.transportation_mode, + ) + metrics = {"co2e": Sum("co2e"), "distance": Sum("distance")} + group_data = entries.aggregate(**metrics) + co2e_cap = group_data["co2e"] / self.working_group.n_employees + + try: + obj = BusinessTripGroup.objects.get( + working_group=self.working_group, + timestamp="{0}-{1}-01".format(year, month), + transportation_mode=self.transportation_mode, + ) + obj.n_employees = self.working_group.n_employees + obj.distance = group_data["distance"] + obj.co2e = group_data["co2e"] + obj.co2e_cap = co2e_cap + obj.save() + except BusinessTripGroup.DoesNotExist: + BusinessTripGroup( + working_group=self.working_group, + timestamp="{0}-{1}-01".format(year, month), + transportation_mode=self.transportation_mode, + n_employees=self.working_group.n_employees, + distance=group_data["distance"], + co2e=group_data["co2e"], + co2e_cap=co2e_cap, + ).save() + + def delete(self): + """Recalculate the emission of the respective working group when a user delets a business trip""" + # Calculate monthly co2 + super(BusinessTrip, self).delete() + entries = BusinessTrip.objects.filter( + working_group=self.working_group, + timestamp__year=self.timestamp.year, + timestamp__month=self.timestamp.month, + transportation_mode=self.transportation_mode, + ) + print(entries) + if len(entries) == 0: + co2e = 0 + co2e_cap = 0 + distance = 0 + else: + metrics = {"co2e": Sum("co2e"), "distance": Sum("distance")} + group_data = entries.aggregate(**metrics) + co2e = group_data["co2e"] + distance = group_data["distance"] + co2e_cap = co2e / self.working_group.n_employees + + try: + obj = BusinessTripGroup.objects.get( + working_group=self.working_group, + timestamp="{0}-{1}-01".format( + self.timestamp.year, self.timestamp.month + ), + transportation_mode=self.transportation_mode, + ) + obj.n_employees = self.working_group.n_employees + obj.distance = distance + obj.co2e = co2e + obj.co2e_cap = co2e_cap + obj.save() + except BusinessTripGroup.DoesNotExist: + BusinessTripGroup( + working_group=self.working_group, + timestamp="{0}-{1}-01".format( + self.timestamp.year, self.timestamp.month + ), + transportation_mode=self.transportation_mode, + n_employees=self.working_group.n_employees, + distance=distance, + co2e=co2e, + co2e_cap=co2e_cap, + ).save() + + def __str__(self): + return f"{self.user.first_name} {self.user.last_name}, {self.transportation_mode}, {self.timestamp}" \ No newline at end of file diff --git a/backend/src/emissions/models/commuting.py b/backend/src/emissions/models/commuting.py new file mode 100644 index 00000000..d6617c3c --- /dev/null +++ b/backend/src/emissions/models/commuting.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" Commuting Models """ + + +from django.db import models + +from emissions.models import (CustomUser, WorkingGroup) + +from co2calculator.co2calculator.constants import TransportationMode + + +class CommutingGroup(models.Model): + """Monthly emissions from commuting per working group""" + + working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE, null=True) + timestamp = models.DateField(null=False) + n_employees = models.IntegerField(null=False) + transportation_mode = models.CharField(max_length=30) + distance = models.FloatField(null=True) + co2e = models.FloatField() + co2e_cap = models.FloatField() + + def __str__(self): + return ( + f"{self.working_group.name}, {self.transportation_mode}, {self.timestamp}" + ) + + +class Commuting(models.Model): + """Monthly emissions from commuting of an employee""" + + user = models.ForeignKey(CustomUser, on_delete=models.CASCADE) + working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE, null=True) + timestamp = models.DateField(null=False) + co2e = models.FloatField() + distance = models.FloatField() + transportation_choices = [(x.name, x.value) for x in TransportationMode] + transportation_mode = models.CharField( + max_length=15, + choices=transportation_choices, + blank=False, + ) + + class Meta: + """Specifies which attributes must be unique together""" + + unique_together = ("user", "timestamp", "transportation_mode") + + def __str__(self): + return f"{self.user.first_name} {self.user.last_name}, {self.transportation_mode}, {self.timestamp}" diff --git a/backend/src/emissions/models/customUser.py b/backend/src/emissions/models/customUser.py new file mode 100644 index 00000000..16fa4b0f --- /dev/null +++ b/backend/src/emissions/models/customUser.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" Custom User Model """ + +from django.db import models +from django.contrib.auth.models import AbstractUser + +import uuid + +class CustomUser(AbstractUser): + """Custom user model""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + email = models.EmailField( + blank=False, max_length=255, verbose_name="email", unique=True + ) + username = models.CharField(max_length=100, unique=True) + first_name = models.CharField(max_length=25, blank=True) + last_name = models.CharField(max_length=25, blank=True) + title_choices = [("PROF", "Prof."), ("DR", "Dr.")] + academic_title = models.CharField(max_length=10, choices=title_choices, blank=True) + working_group = models.ForeignKey("WorkingGroup", on_delete=models.SET_NULL, null=True, blank=True) + is_representative = models.BooleanField(default=False) + + USERNAME_FIELD = "email" + EMAIL_FIELD = "email" + REQUIRED_FIELDS = [] + + def __str__(self): + return f"{self.first_name} {self.last_name}" \ No newline at end of file diff --git a/backend/src/emissions/models/electricity.py b/backend/src/emissions/models/electricity.py new file mode 100644 index 00000000..e72f112d --- /dev/null +++ b/backend/src/emissions/models/electricity.py @@ -0,0 +1,37 @@ + +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" Electricity Model """ + +from django.db import models +from django.core.validators import MinValueValidator, MaxValueValidator + +from emissions.models import WorkingGroup + +from co2calculator.co2calculator.constants import ElectricityFuel + + +class Electricity(models.Model): + """Monthly emissions from electricity consumption of a working group""" + + working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE) + consumption = models.FloatField(null=False) + timestamp = models.DateField(null=False) + building = models.CharField(null=False, max_length=30) + group_share = models.FloatField( + null=False, validators=[MinValueValidator(0.0), MaxValueValidator(1.0)] + ) + fuel_type_choices = [(x.name, x.value) for x in ElectricityFuel] + fuel_type = models.CharField(max_length=40, choices=fuel_type_choices, blank=False) + + co2e = models.FloatField() + co2e_cap = models.FloatField() + + class Meta: + """Specifies which attributes must be unique together""" + + unique_together = ("working_group", "timestamp", "fuel_type", "building") + + def __str__(self): + return f"{self.working_group.name}, {self.timestamp}, {self.fuel_type}, {self.building}" \ No newline at end of file diff --git a/backend/src/emissions/models/heating.py b/backend/src/emissions/models/heating.py new file mode 100644 index 00000000..b5b03cef --- /dev/null +++ b/backend/src/emissions/models/heating.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" Heating Model """ + +from django.db import models +from django.core.validators import MinValueValidator, MaxValueValidator + +from emissions.models import WorkingGroup + +from co2calculator.co2calculator.constants import HeatingFuel + +class Heating(models.Model): + """Monthly emissions from heating consumption of a working group""" + + working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE) + consumption = models.FloatField(null=False, validators=[MinValueValidator(0.0)]) + timestamp = models.DateField(null=False) + building = models.CharField(null=False, max_length=30) + group_share = models.FloatField( + null=False, validators=[MinValueValidator(0.0), MaxValueValidator(1.0)] + ) + fuel_type_choices = [(x.name, x.value) for x in HeatingFuel] + fuel_type = models.CharField(max_length=20, choices=fuel_type_choices, blank=False) + unit_choices = [(x, x) for x in ["kWh", "l", "kg", "m^3"]] + unit = models.CharField(max_length=20, choices=unit_choices, blank=False) + co2e = models.FloatField() + co2e_cap = models.FloatField() + + class Meta: + """Specifies which attributes must be unique together""" + + unique_together = ("working_group", "timestamp", "fuel_type", "building") + + def __str__(self): + return f"{self.working_group.name}, {self.timestamp}, {self.fuel_type}, {self.building}" \ No newline at end of file diff --git a/backend/src/emissions/models/institution.py b/backend/src/emissions/models/institution.py new file mode 100644 index 00000000..9df396ed --- /dev/null +++ b/backend/src/emissions/models/institution.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" Institution Model """ + +from django.db import models +import uuid + + +class Institution(models.Model): + """Research Institution""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) + name = models.CharField(max_length=200, null=False, blank=False) + city = models.CharField(max_length=100, null=False, blank=False) + state = models.CharField(max_length=100, null=True) + country = models.CharField(max_length=100, null=False, blank=False) + + class Meta: + """Specifies which attributes must be unique together""" + + unique_together = ("name", "city", "country") + + def __str__(self): + return f"{self.name}, {self.city}, {self.country}" \ No newline at end of file diff --git a/backend/src/emissions/models/researchField.py b/backend/src/emissions/models/researchField.py new file mode 100644 index 00000000..e0b2d2ec --- /dev/null +++ b/backend/src/emissions/models/researchField.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" Research Field Model """ + +from django.db import models + + +class ResearchField(models.Model): + """Research field""" + + id = models.IntegerField(primary_key=True, null=False, blank=False) + field = models.CharField(max_length=100, null=False, blank=False) + subfield = models.CharField(max_length=100, null=False, blank=False) + + class Meta: + """Specifies which attributes must be unique together""" + + unique_together = ("field", "subfield") + + def __str__(self): + return f"{self.field} - {self.subfield}" \ No newline at end of file diff --git a/backend/src/emissions/models/workingGroup.py b/backend/src/emissions/models/workingGroup.py new file mode 100644 index 00000000..6f38fc21 --- /dev/null +++ b/backend/src/emissions/models/workingGroup.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" Working Group Model """ + +from django.db import models +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + + +import uuid + +from emissions.models import (CustomUser, Institution, ResearchField) + + +class WorkingGroup(models.Model): + """Working group at a research institution""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) + name = models.CharField(max_length=200, blank=False) + institution = models.ForeignKey(Institution, on_delete=models.PROTECT, null=True) + representative = models.OneToOneField( + CustomUser, on_delete=models.PROTECT, null=True + ) + n_employees = models.IntegerField(null=True, blank=True) + field = models.ForeignKey(ResearchField, on_delete=models.PROTECT, null=False) + is_public = models.BooleanField(null=False, default=False) + + class Meta: + """Specifies which attributes must be unique together""" + + unique_together = ("name", "institution") + + def clean(self, *args, **kwargs): + """Verify that the representative of the working group is a member of the working group""" + # add custom validation here + if (self.representative.working_group != self) and ( + self.representative.working_group is not None + ): + raise ValidationError( + _( + "This user cannot become the group representative, since they are not a member of this working group." + ), + code="invalid", + ) + super().clean(*args, **kwargs) + + def save(self, *args, **kwargs): + """ + Updates the user who is the representative of the working group + :param args: + :param kwargs: + :return: + """ + self.full_clean() + super(WorkingGroup, self).save(*args, **kwargs) + + def __str__(self): + return f"{self.name}, {self.institution.name}, {self.institution.city}, {self.institution.country}" \ No newline at end of file diff --git a/backend/src/emissions/models/workingGroupJoinRequest.py b/backend/src/emissions/models/workingGroupJoinRequest.py new file mode 100644 index 00000000..a8a059c1 --- /dev/null +++ b/backend/src/emissions/models/workingGroupJoinRequest.py @@ -0,0 +1,17 @@ +from django.db import models +from emissions.models import CustomUser, WorkingGroup +import uuid + + +class WorkingGroupJoinRequest(models.Model): + """Request of a user to join a working group""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(CustomUser, on_delete=models.CASCADE, blank=False, null=False) + working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE, null=False) + timestamp = models.DateTimeField(auto_now_add=True, null=False) + status_choices = [('Pending', 'pending'), ('Approved', 'approved'), ('Declined', 'declined')] + status = models.CharField(max_length=50, choices=status_choices, null=False, blank=False) + + def __str__(self): + return f"{self.user.first_name}, {self.user.last_name}, {self.timestamp}" diff --git a/backend/src/emissions/schema.py b/backend/src/emissions/schema.py index c1251a9f..6341041a 100644 --- a/backend/src/emissions/schema.py +++ b/backend/src/emissions/schema.py @@ -1,441 +1,1304 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""GraphQL endpoints""" + +__email__ = "infopledge4future.org" + +import os +import traceback + import graphene -from django.db.models import Sum, CharField, Value +import datetime as dt +import pandas as pd +from django.core.exceptions import ValidationError +from django.db.models import Sum, F +import numpy as np + from django.db.models.functions import TruncMonth, TruncYear from graphene_django.types import DjangoObjectType, ObjectType from graphql import GraphQLError from graphql_auth.schema import UserQuery, MeQuery from graphql_auth import mutations -from emissions.models import BusinessTrip, User, Electricity, WorkingGroup, Heating, Institution, Commuting, CommutingGroup, BusinessTripGroup -from co2calculator.co2calculator.calculate import calc_co2_electricity, calc_co2_heating, calc_co2_businesstrip, calc_co2_commuting -from graphene_django.filter import DjangoFilterConnectionField -from emissions.graphene_utils import get_fields - -import numpy as np +from emissions.models import ( + BusinessTrip, + CustomUser, + Electricity, + WorkingGroup, + Heating, + Institution, + Commuting, + CommutingGroup, + BusinessTripGroup, + ResearchField, + WorkingGroupJoinRequest +) +from emissions.decorators import representative_required + +from co2calculator.co2calculator.calculate import ( + calc_co2_electricity, + calc_co2_heating, + calc_co2_businesstrip, + calc_co2_commuting, +) +from co2calculator.co2calculator.constants import ElectricityFuel + +from graphql_jwt.decorators import login_required +import warnings + +from emissions.email_client import EmailClient +from django.conf import settings # -------------- GraphQL Types ------------------- WEEKS_PER_MONTH = 4.34524 WEEKS_PER_YEAR = 52.1429 + class UserType(DjangoObjectType): + """GraphQL User Type""" + class Meta: - model = User + """Assign django model""" + + model = CustomUser class WorkingGroupType(DjangoObjectType): + """GraphQL Working Group Type""" + class Meta: + """Assign django model""" + model = WorkingGroup +class WorkingGroupJoinRequestType(DjangoObjectType): + """GraphQL Working Group Type""" + + class Meta: + """Assign django model""" + + model = WorkingGroupJoinRequest + + class InstitutionType(DjangoObjectType): + """GraphQL Institution""" + class Meta: + """Assign django model""" + model = Institution +class ResearchFieldType(DjangoObjectType): + """GraphQL Research Field""" + + class Meta: + """Assign django model""" + + model = ResearchField + + class BusinessTripType(DjangoObjectType): + """GraphQL Business Trip Type""" + class Meta: + """Assign django model""" + model = BusinessTrip + class CommutingType(DjangoObjectType): + """GraphQL Commuting Type""" + class Meta: + """Assign django model""" + model = Commuting + class ElectricityType(DjangoObjectType): + """GraphQL Electricity Type""" + class Meta: + """Assign django model""" + model = Electricity class HeatingType(DjangoObjectType): + """GraphQL Heating Type""" + class Meta: + """Assign django model""" + model = Heating class HeatingAggregatedType(ObjectType): - date = graphene.String() - co2e = graphene.Float() - co2e_cap = graphene.Float() + """GraphQL Heating aggregated by month or year""" + + date = graphene.String(description="Date") + co2e = graphene.Float(description="Total CO2e emissions [tco2e]") + co2e_cap = graphene.Float(description="CO2e emissions per capita [tco2e]") class Meta: + """Assign django model""" + name = "HeatingAggregated" - filter_fields = ['group_id'] + filter_fields = ["id"] class ElectricityAggregatedType(ObjectType): - date = graphene.String() - co2e = graphene.Float() - co2e_cap = graphene.Float() + """GraphQL Electricity aggregated by month or year""" + + date = graphene.String(description="Date") + co2e = graphene.Float(description="Total CO2e emissions [tco2e]") + co2e_cap = graphene.Float(description="CO2e emissions per capita [tco2e]") class Meta: + """Assign django model""" + name = "ElectricityAggregated" class BusinessTripAggregatedType(ObjectType): - date = graphene.String() - co2e = graphene.Float() - co2e_cap = graphene.Float() + """GraphQL Business Trips aggregated by month or year""" + + date = graphene.String(description="Date") + co2e = graphene.Float(description="Total CO2e emissions [tco2e]") + co2e_cap = graphene.Float(description="CO2e emissions per capita [tco2e]") class Meta: + """Assign django model""" + name = "BusinessTripAggregated" class CommutingAggregatedType(ObjectType): - date = graphene.String() - co2e = graphene.Float() - co2e_cap = graphene.Float() + """GraphQL Commuting aggregated by month or year""" + + date = graphene.String(description="Date") + co2e = graphene.Float(description="Total CO2e emissions [tco2e]") + co2e_cap = graphene.Float(description="CO2e emissions per capita [tco2e]") class Meta: + """Assign django model""" + name = "CommutingAggregated" + +class TotalEmissionType(ObjectType): + """GraphQL total emissions""" + + working_group_name = graphene.String(description="Name of the working group") + working_group_institution_name = graphene.String( + description="Name of the institution the working group belongs to" + ) + co2e = graphene.Float(description="Total CO2e emissions [tco2e]") + co2e_cap = graphene.Float(description="CO2e emissions per capita [tco2e]") + + class Meta: + """Assign django model""" + + name = "TotalEmission" + + # -------------------- Query types ----------------- # Create a Query type class Query(UserQuery, MeQuery, ObjectType): + """GraphQL Queries""" + businesstrips = graphene.List(BusinessTripType) electricities = graphene.List(ElectricityType) heatings = graphene.List(HeatingType) commutings = graphene.List(CommutingType) - working_groups = graphene.List(WorkingGroupType) + workinggroups = graphene.List(WorkingGroupType) + researchfields = graphene.List(ResearchFieldType) + institutions = graphene.List(InstitutionType) + workinggroup_users = graphene.List(UserType) + join_requests = graphene.List(WorkingGroupJoinRequestType) # Aggregated data - heating_aggregated = graphene.List(HeatingAggregatedType, - group_id=graphene.UUID(), - inst_id=graphene.UUID(), - time_interval=graphene.String()) - electricity_aggregated = graphene.List(ElectricityAggregatedType, - group_id=graphene.UUID(), - inst_id=graphene.UUID(), - time_interval=graphene.String()) - businesstrip_aggregated = graphene.List(BusinessTripAggregatedType, - username=graphene.String(), - group_id=graphene.UUID(), - inst_id=graphene.UUID(), - time_interval=graphene.String()) - commuting_aggregated = graphene.List(CommutingAggregatedType, - username=graphene.String(), - group_id=graphene.UUID(), - inst_id=graphene.UUID(), - time_interval=graphene.String()) - + heating_aggregated = graphene.List( + HeatingAggregatedType, + level=graphene.String( + description="Aggregation level: group or institution. Default: group" + ), + time_interval=graphene.String( + description="Time interval for aggregation (month or year)" + ), + ) + electricity_aggregated = graphene.List( + ElectricityAggregatedType, + level=graphene.String( + description="Aggregation level: group or institution. Default: group" + ), + time_interval=graphene.String( + description="Time interval for aggregation (month or year)" + ), + ) + businesstrip_aggregated = graphene.List( + BusinessTripAggregatedType, + level=graphene.String( + description="Aggregation level: personal, group or institution. Default: group" + ), + time_interval=graphene.String( + description="Time interval for aggregation (month or year)" + ), + ) + commuting_aggregated = graphene.List( + CommutingAggregatedType, + level=graphene.String( + description="Aggregation level: personal, group or institution. Default: group" + ), + time_interval=graphene.String( + description="Time interval for aggregation (month or year)" + ), + ) + total_emission = graphene.List( + TotalEmissionType, + start=graphene.Date( + description="Start date for calculation of total emissions" + ), + end=graphene.Date(description="End date for calculation of total emissions"), + level=graphene.String( + description="Aggregate by 'group' or 'institution'. Default: 'group')" + ), + ) + + @login_required + def resolve_workinggroups(self, info, **kwargs): + """Yields all working group objects""" + return WorkingGroup.objects.filter(is_public=True) + + @login_required + @representative_required + def resolve_workinggroup_users(self, info, **kawrgs): + """Returns the users of a certain working group.""" + id = info.context.user.working_group.id + return CustomUser.objects.filter(working_group__id=id) + + @login_required + @representative_required + def resolve_join_requests(self, info, **kwargs): + """Yields all institution objects""" + id = info.context.user.working_group.id + return WorkingGroupJoinRequest.objects.filter(working_group__id=id) + + def resolve_institutions(self, info, **kwargs): + """Yields all institution objects""" + return Institution.objects.all() + + def resolve_researchfields(self, info, **kwargs): + """Yields all reseach field objects""" + return ResearchField.objects.all() + + @login_required def resolve_businesstrips(self, info, **kwargs): - """ Yields all heating consumption objects""" - return BusinessTrip.objects.all() + """Yields all heating consumption objects""" + user = info.context.user + return BusinessTrip.objects.all(user__id=user.id) + @login_required def resolve_electricities(self, info, **kwargs): - """ Yields all heating consumption objects""" - return Electricity.objects.all() + """Yields all heating consumption objects""" + user = info.context.user + return Electricity.objects.all(working_group__id=user.working_group.id) + @login_required def resolve_heatings(self, info, **kwargs): - """ Yields all heating consumption objects""" - return Heating.objects.all() - - def resolve_commutingss(self, info, **kwargs): - """ Yields all heating consumption objects""" - return Commuting.objects.all() - - def resolve_working_groups(self, info, **kwargs): - """ Yields all working group objects """ - return WorkingGroup.objects.all() - - def resolve_heating_aggregated(self, info, group_id=None, inst_id=None, time_interval="month", **kwargs): + """Yields all heating consumption objects""" + user = info.context.user + return Heating.objects.all(working_group__id=user.working_group.id) + + @login_required + def resolve_commutings(self, info, **kwargs): + """Yields all heating consumption objects""" + user = info.context.user + return Commuting.objects.all(user__id=user.id) + + @login_required + def resolve_heating_aggregated( + self, info, level="group", time_interval="month", **kwargs + ): """ - Yields monthly co2e emissions (per capita) of heating consumption - - for a group (if group_id is given), - - for an institution (if inst_id is given) - param: username: username of user model (str) - param: group_id: UUID id of WorkingGroup model (str) - param: inst_id: UUID id of Institute model (str) + Yields monthly co2e emissions (per capita) of heating consumption, for the user, their group or their institution + param: level: Aggregation level: personal, group or institution. Default: group param: time_interval: Aggregate co2e per "month" or "year" """ + #if not info.context.user.is_authenticated: + # raise GraphQLError("User is not authenticated.") + user = info.context.user + + if user.working_group is None: + raise GraphQLError("No heating data available, since user is not assigned to any working group yet.") + # Get relevant data entries - if group_id: - entries = Heating.objects.filter(working_group__group_id=group_id) - elif inst_id: - entries = Heating.objects.filter(working_group__institution__inst_id=inst_id) + if level == "personal": + entries = Heating.objects.filter(working_group__id=user.working_group.id) + # Use the average co2e emissions per capital as total emissons for one person + metrics = {"co2e": Sum("co2e_cap"), "co2e_cap": Sum("co2e_cap")} + elif level == "group": + entries = Heating.objects.filter(working_group__id=user.working_group.id) + metrics = {"co2e": Sum("co2e"), "co2e_cap": Sum("co2e_cap")} + elif level == "institution": + entries = Heating.objects.filter( + working_group__institution__id=user.working_group.institution.id + ) + metrics = {"co2e": Sum("co2e"), "co2e_cap": Sum("co2e_cap")} else: - entries = Heating.objects.all() + raise GraphQLError(f"Invalid value for parameter 'level': {level}") - metrics = { - 'co2e': Sum('co2e'), - 'co2e_cap': Sum('co2e_cap') - } - - # Annotate based on groupby - #if groupby == "total": - # return not entries.annotate(date=Value('total', output_field=CharField()))\ - # .values('date')\ - # .annotate(co2e=Sum("co2e"))\ - # .order_by('date') - # #.annotate(co2e_cap=Sum("co2e_cap"))\ - # #.order_by('date') if time_interval == "month": - return entries.annotate(date=TruncMonth('timestamp')).values('date').annotate(**metrics).order_by('date') + return ( + entries.annotate(date=TruncMonth("timestamp")) + .values("date") + .annotate(**metrics) + .order_by("date") + ) elif time_interval == "year": - return entries.annotate(date=TruncYear('timestamp'))\ - .values('date')\ - .annotate(**metrics) \ - .order_by('date') + return ( + entries.annotate(date=TruncYear("timestamp")) + .values("date") + .annotate(**metrics) + .order_by("date") + ) else: raise GraphQLError(f"Invalid option {time_interval} for 'time_interval'.") - def resolve_electricity_aggregated(self, info, group_id=None, inst_id=None, time_interval="month", **kwargs): + @login_required + def resolve_electricity_aggregated( + self, info, level="group", time_interval="month", **kwargs + ): """ - Yields monthly co2e emissions of electricity consumption - - for a group (if group_id is given), - - for an institutions (if inst_id is given) - param: username: username of user model (str) - param: group_id: UUID id of WorkingGroup model (str) - param: inst_id: UUID id of Institute model (str) + Yields monthly co2e emissions (per capita) of electricity consumption, for the user, their group or their institution + param: level: Aggregation level: group or institution. Default: group param: time_interval: Aggregate co2e per "month" or "year" """ - if group_id: - entries = Electricity.objects.filter(working_group__group_id=group_id) - elif inst_id: - entries = Electricity.objects.filter(working_group__institution__inst_id=inst_id) + user = info.context.user + if user.working_group is None: + raise GraphQLError("No heating data available, since user is not assigned to any working group yet.") + + # Get relevant data entries + if level == "personal": + entries = Electricity.objects.filter(working_group__id=user.working_group.id) + # Use the average co2e emissions per capital as total emissons for one person + metrics = {"co2e": Sum("co2e_cap"), "co2e_cap": Sum("co2e_cap")} + elif level == "group": + entries = Electricity.objects.filter(working_group__id=user.working_group.id) + metrics = {"co2e": Sum("co2e"), "co2e_cap": Sum("co2e_cap")} + elif level == "institution": + entries = Electricity.objects.filter( + working_group__institution__id=user.working_group.institution.id + ) + metrics = {"co2e": Sum("co2e"), "co2e_cap": Sum("co2e_cap")} else: - entries = Electricity.objects.all() - metrics = { - 'co2e': Sum('co2e'), - 'co2e_cap': Sum('co2e_cap') - } + raise GraphQLError(f"Invalid value for parameter 'level': {level}") + if time_interval == "month": - return entries.annotate(date=TruncMonth('timestamp')).values('date').annotate(**metrics).order_by('date') + return ( + entries.annotate(date=TruncMonth("timestamp")) + .values("date") + .annotate(**metrics) + .order_by("date") + ) elif time_interval == "year": - return entries.annotate(date=TruncYear('timestamp'))\ - .values('date')\ - .annotate(**metrics) \ - .order_by('date') + return ( + entries.annotate(date=TruncYear("timestamp")) + .values("date") + .annotate(**metrics) + .order_by("date") + ) else: raise GraphQLError(f"Invalid option {time_interval} for 'time_interval'.") - def resolve_businesstrip_aggregated(self, info, username=None, group_id=None, inst_id=None, time_interval="month", **kwargs): + @login_required + def resolve_businesstrip_aggregated( + self, + info, + level="group", + time_interval="month", + **kwargs, + ): """ Yields monthly co2e emissions of businesstrips - - for a user (if username is given), + - for a user, - for a group (if group_id is given), - for an institution (if inst_id is given) - param: username: username of user model (str) - param: group_id: UUID id of WorkingGroup model (str) - param: inst_id: UUID id of Institute model (str) + param: level: Aggregation level: personal, group or institution. Default: group param: time_interval: Aggregate co2e per "month" or "year" """ - if username: - entries = BusinessTrip.objects.filter(user__username=username) - elif group_id: - entries = BusinessTripGroup.objects.filter(working_group__group_id=group_id) - elif inst_id: - entries = BusinessTripGroup.objects.filter(working_group__institution__inst_id=inst_id) + user = info.context.user + # Get relevant data entries + if level == "personal": + entries = BusinessTrip.objects.filter(user__id=user.id) + entries = entries.annotate(co2e_cap=F("co2e")) + elif level == "group": + entries = BusinessTripGroup.objects.filter( + working_group__id=user.working_group.id + ) + elif level == "institution": + entries = BusinessTripGroup.objects.filter( + working_group__institution__id=user.working_group.institution.id + ) else: - entries = BusinessTrip.objects.all() + raise GraphQLError(f"Invalid value for parameter 'level': {level}") - metrics = { - 'co2e': Sum('co2e'), - } - if not username: - metrics['co2e_cap'] = Sum('co2e_cap') - - if time_interval.lower() == "month": - return entries.annotate(date=TruncMonth('timestamp')).values('date').annotate(**metrics).order_by('date') - elif time_interval.lower() == "year": - return entries.annotate(date=TruncYear('timestamp'))\ - .values('date')\ - .annotate(**metrics) \ - .order_by('date') - else: - raise GraphQLError(f"'{time_interval}' is not a valid option for parameter 'time_interval'.") + metrics = {"co2e": Sum("co2e"), "co2e_cap": Sum("co2e_cap")} + if time_interval == "month": + return ( + entries.annotate(date=TruncMonth("timestamp")) + .values("date") + .annotate(**metrics) + .order_by("date") + ) + elif time_interval == "year": + return ( + entries.annotate(date=TruncYear("timestamp")) + .values("date") + .annotate(**metrics) + .order_by("date") + ) + else: + raise GraphQLError(f"Invalid option {time_interval} for 'time_interval'.") - def resolve_commuting_aggregated(self, info, username=None, group_id=None, inst_id=None, time_interval="month", **kwargs): + @login_required + def resolve_commuting_aggregated( + self, + info, + level="group", + time_interval="month", + **kwargs, + ): """ Yields monthly co2e emissions of businesstrips - - for a user (if username is given), + - for a user, - for a group (if group_id is given), - for an institution (if inst_id is given) - param: username: username of user model (str) - param: group_id: UUID id of WorkingGroup model (str) - param: inst_id: UUID id of Institute model (str) + param: level: Aggregation level: group or institution. Default: group param: time_interval: Aggregate co2e per "month" or "year" """ - metrics = { - 'co2e': Sum('co2e'), - 'co2e_cap': Sum('co2e_cap'), - } - if username: - entries = Commuting.objects.filter(user__username=username) - metrics.pop("co2e_cap") - elif group_id: - entries = CommutingGroup.objects.filter(working_group__group_id=group_id) - elif inst_id: - entries = CommutingGroup.objects.filter(working_group__institution__inst_id=inst_id) + user = info.context.user + # Get relevant data entries + if level == "personal": + entries = Commuting.objects.filter(user__id=user.id) + entries = entries.annotate(co2e_cap=F("co2e")) + elif level == "group": + entries = CommutingGroup.objects.filter( + working_group__id=user.working_group.id + ) + elif level == "institution": + entries = CommutingGroup.objects.filter( + working_group__institution__id=user.working_group.institution.id + ) else: - entries = Commuting.objects.all() - - if time_interval.lower() == "month": - return entries.annotate(date=TruncMonth('timestamp'))\ - .values('date')\ - .annotate(**metrics)\ - .order_by('date') - elif time_interval.lower() == "year": - return entries.annotate(date=TruncYear('timestamp'))\ - .values('date')\ - .annotate(**metrics) \ - .order_by('date') + raise GraphQLError(f"Invalid value for parameter 'level': {level}") + + metrics = {"co2e": Sum("co2e"), "co2e_cap": Sum("co2e_cap")} + + if time_interval == "month": + return ( + entries.annotate(date=TruncMonth("timestamp")) + .values("date") + .annotate(**metrics) + .order_by("date") + ) + elif time_interval == "year": + return ( + entries.annotate(date=TruncYear("timestamp")) + .values("date") + .annotate(**metrics) + .order_by("date") + ) else: - raise GraphQLError(f"'{time_interval}' is not a valid option for parameter 'time_interval'.") + raise GraphQLError(f"Invalid option {time_interval} for 'time_interval'.") + def resolve_total_emission( + self, info, start=None, end=None, level="group", **kwargs + ): + """ + Yields total emissions on monthly or yearly basis + param: start: Start date for calculation of total emissions. If none is given, the last 12 months will be used. + param: end: end date for calculation of total emission If none is given, the last 12 months will be used. + """ + metrics = { + "co2e": Sum("co2e"), + "co2e_cap": Sum("co2e_cap"), + } + if not end and not start: + end = dt.datetime( + day=1, + month=dt.datetime.today().month - 1, + year=dt.datetime.today().year, + ) + start = dt.datetime( + day=1, + month=dt.datetime.today().month, + year=dt.datetime.today().year - 1, + ) + + if level == "group": + aggregate_on = ["working_group__name", "working_group__institution__name"] + elif level == "institution": + aggregate_on = ["working_group__institution__name"] + else: + raise GraphQLError(f"Invalid value for parameter 'level': {level}") + + heating_emissions = ( + Heating.objects.filter(timestamp__gte=start, timestamp__lte=end) + .values(*aggregate_on) + .annotate(**metrics) + ) + heating_df = pd.DataFrame(list(heating_emissions)) + + electricity_emissions = ( + Electricity.objects.filter(timestamp__gte=start, timestamp__lte=end) + .values(*aggregate_on) + .annotate(**metrics) + ) + electricity_df = pd.DataFrame(list(electricity_emissions)) + + businesstrips_emissions = ( + BusinessTripGroup.objects.filter(timestamp__gte=start, timestamp__lte=end) + .values(*aggregate_on) + .annotate(**metrics) + ) + businesstrips_df = pd.DataFrame(list(businesstrips_emissions)) + + commuting_emissions = ( + CommutingGroup.objects.filter(timestamp__gte=start, timestamp__lte=end) + .values(*aggregate_on) + .annotate(**metrics) + ) + commuting_df = pd.DataFrame(list(commuting_emissions)) + + total_emissions = ( + pd.concat([heating_df, electricity_df, commuting_df, businesstrips_df]) + .groupby(aggregate_on) + .sum() + ) + total_emissions.reset_index(inplace=True) + + emissions_for_groups = [] + if level == "group": + for _, row in total_emissions.iterrows(): + section = TotalEmissionType( + working_group_name=row["working_group__name"], + working_group_institution_name=row[ + "working_group__institution__name" + ], + co2e=row["co2e"], + co2e_cap=row["co2e_cap"], + ) + emissions_for_groups.append(section) + elif level == "institution": + for _, row in total_emissions.iterrows(): + section = TotalEmissionType( + working_group_name=None, + working_group_institution_name=row[ + "working_group__institution__name" + ], + co2e=row["co2e"], + co2e_cap=row["co2e_cap"], + ) + emissions_for_groups.append(section) + return emissions_for_groups # -------------- Input Object Types -------------------------- +class JoinRequestInput(graphene.InputObjectType): + """GraphQL Input type for sending request to join a working group""" + + workinggroup_id = graphene.String(reqired=True, description="ID of the working group") + class CommutingInput(graphene.InputObjectType): - id = graphene.ID() - username = graphene.String(required=True) - from_timestamp = graphene.Date(required=True) - to_timestamp = graphene.Date(required=True) - transportation_mode = graphene.String(required=True) - workweeks = graphene.Int() - distance = graphene.Float() - size = graphene.String() - fuel_type = graphene.String() - occupancy = graphene.Float() - passengers = graphene.Int() + """GraphQL Input type for commuting""" + + from_timestamp = graphene.Date(required=True, description="Start date") + to_timestamp = graphene.Date(required=True, description="End date") + transportation_mode = graphene.String( + required=True, description="Transportation mode" + ) + workweeks = graphene.Int(description="Number of work weeks") + distance = graphene.Float(description="Distance [meter]") + size = graphene.String(description="Size of the vehicle") + fuel_type = graphene.String(description="Fuel type of the vehicle") + occupancy = graphene.Float(description="Occupancy of the vehicle") + passengers = graphene.Int(description="Number of passengers in the vehicle") + + +class PlanTripInput(graphene.InputObjectType): + """GraphQL Input type for the trip planner""" + + transportation_mode = graphene.String( + required=True, description="Transportation mode" + ) + start_address = graphene.String(description="Start address") + start_city = graphene.String(description="Start city") + start_country = graphene.String(description="Start country") + destination_address = graphene.String(description="Destination address") + destination_city = graphene.String(description="Destination city") + destination_country = graphene.String(description="Destination country") + distance = graphene.Float(description="Distance [meter]") + size = graphene.String(description="Size of the vehicle") + fuel_type = graphene.String(description="Fuel type of the vehicle") + occupancy = graphene.Float(description="Occupancy") + seating_class = graphene.String(description="Seating class in plane") + passengers = graphene.Int(description="Number of passengers") + roundtrip = graphene.Boolean(description="Roundtrip [True/False]") class BusinessTripInput(graphene.InputObjectType): - id = graphene.ID() - username = graphene.String(required=True) - group_id = graphene.UUID(required=True) - timestamp = graphene.Date(required=True) - transportation_mode = graphene.String(required=True) - start = graphene.String() - destination = graphene.String() - distance = graphene.Float() - size = graphene.String() - fuel_type = graphene.String() - occupancy = graphene.Float() - seating_class = graphene.Int() - passengers = graphene.Int() - roundtrip = graphene.Boolean() + """GraphQL Input type for Business trips""" + + timestamp = graphene.Date(required=True, description="Date") + transportation_mode = graphene.String( + required=True, description="Transportation mode" + ) + start = graphene.String(description="Start address") + destination = graphene.String(description="Destination address") + distance = graphene.Float(description="Distance [meter]") + size = graphene.String(description="Size of the vehicle") + fuel_type = graphene.String(description="Fuel type of the vehicle") + occupancy = graphene.Float(description="Occupancy") + seating_class = graphene.String(description="Seating class in plane") + passengers = graphene.Int(description="Number of passengers") + roundtrip = graphene.Boolean(description="Roundtrip [True/False]") class ElectricityInput(graphene.InputObjectType): - id = graphene.ID() - group_id = graphene.UUID() - timestamp = graphene.Date(required=True) - consumption = graphene.Float() - fuel_type = graphene.String(required=True) - building = graphene.String(required=True) - group_share = graphene.Float(required=True) + """GraphQL Input type for electricity""" + + timestamp = graphene.Date(required=True, description="Date") + consumption = graphene.Float(description="Consumption") + fuel_type = graphene.String(required=True, description="Fuel type") + building = graphene.String( + required=True, description="Number of Building if there are several ones" + ) + group_share = graphene.Float( + required=True, description="Share of the building beloning to the working group" + ) class HeatingInput(graphene.InputObjectType): - id = graphene.ID() - group_id = graphene.UUID() - timestamp = graphene.Date(required=True) - consumption = graphene.Float(required=True) - unit = graphene.String(required=True) - fuel_type = graphene.String(required=True) - building = graphene.String(required=True) - group_share = graphene.Float(required=True) - - -class UserInput(graphene.InputObjectType): - id = graphene.ID() - workinggroupid = graphene.Int() - email = graphene.String() - username = graphene.String() - first_name = graphene.String() - last_name = graphene.String() - is_representative = graphene.Boolean() + """GraphQL Input type for heating""" + + timestamp = graphene.Date(required=True, description="Date") + consumption = graphene.Float(required=True, description="Consumption") + unit = graphene.String(required=True, description="Unit of fuel type") + fuel_type = graphene.String(required=True, description="Fuel type") + building = graphene.String( + required=True, description="Number of Building if there are several ones" + ) + group_share = graphene.Float( + required=True, description="Share of the building beloning to the working group" + ) + + +class CreateWorkingGroupInput(graphene.InputObjectType): + """GraphQL Input type for creating a new working group""" + + name = graphene.String(reqired=True, description="Name of the working group") + institution_id = graphene.String( + required=True, description="UUID of institution the working group belongs to" + ) + research_field_id = graphene.Int( + required=True, description="ID of Research field of working group" + ) + n_employees = graphene.Int( + required=True, description="Number of employees of working group" + ) + is_public = graphene.Boolean(required=True, + description="If true, the group will be publicly visible.") + +class DeleteWorkingGroupInput(graphene.InputObjectType): + """GraphQL Input type for deleting an existing working group""" + + id = graphene.String(required=True, description="ID of the working group") + + +class SetWorkingGroupInput(graphene.InputObjectType): + """GraphQL Input type for setting working group""" + + id = graphene.String(reqired=True, description="ID of the working group") + +class RemoveUserFromWorkingGroupInput(graphene.InputObjectType): + """GraphQL input type for removing a user from a working group""" + + user_id = graphene.String(required=True, description="ID of the user that should be removed") + + +class AnswerJoinRequestInput(graphene.InputObjectType): + """GraphQL Input type for setting working group""" + + request_id = graphene.String(required=True, description="ID of join request") + approve = graphene.Boolean(reqired=True, description="Approve (true) or decline (false) join request") + +class AddUserToWorkingGroupInput(graphene.InputObjectType): + """GraphQL input type for adding a user to a working group""" + + user_email = graphene.String(required=True, description="eMail of the user that should be added to the working group") # --------------- Mutations ------------------------------------ class AuthMutation(graphene.ObjectType): - register = mutations.Register.Field() - verify_account = mutations.VerifyAccount.Field() - token_auth = mutations.ObtainJSONWebToken.Field() - update_account = mutations.UpdateAccount.Field() - resend_activation_email = mutations.ResendActivationEmail.Field() - send_password_reset_email = mutations.SendPasswordResetEmail.Field() - password_reset = mutations.PasswordReset.Field() - password_change = mutations.PasswordChange.Field() + """Authentication mutations""" + + register = mutations.Register.Field() + verify_account = mutations.VerifyAccount.Field() + resend_activation_email = mutations.ResendActivationEmail.Field() + send_password_reset_email = mutations.SendPasswordResetEmail.Field() + password_reset = mutations.PasswordReset.Field() + password_set = mutations.PasswordSet.Field() + archive_account = mutations.ArchiveAccount.Field() + delete_account = mutations.DeleteAccount.Field() + password_change = mutations.PasswordChange.Field() + update_account = mutations.UpdateAccount.Field() + send_secondary_email_activation = mutations.SendSecondaryEmailActivation.Field() + verify_secondary_email = mutations.VerifySecondaryEmail.Field() + swap_emails = mutations.SwapEmails.Field() + remove_secondary_email = mutations.RemoveSecondaryEmail.Field() + + token_auth = mutations.ObtainJSONWebToken.Field() + verify_token = mutations.VerifyToken.Field() + refresh_token = mutations.RefreshToken.Field() + revoke_token = mutations.RevokeToken.Field() + + +class CreateWorkingGroup(graphene.Mutation): + """Mutation to create a new working group""" + + class Arguments: + """Assign input type""" + input = CreateWorkingGroupInput() + + success = graphene.Boolean() + workinggroup = graphene.Field(WorkingGroupType) + + @staticmethod + @login_required + def mutate(root, info, input=None): + """Process incoming data""" + user = info.context.user + success = True + + institution_found = Institution.objects.filter( + id=input.institution_id + ) + if len(institution_found) == 0: + raise GraphQLError("Institution not found.") + else: + institution = institution_found[0] + + field_found = ResearchField.objects.filter(id=input.research_field_id) + if len(field_found) == 0: + raise GraphQLError("Research field is invalid.") + else: + research_field = field_found[0] + + # Check if working group already exists + exists = WorkingGroup.objects.filter(name=input.name, institution=institution) + if len(exists) > 0: + raise GraphQLError( + "This working group cannot be created, because it already exists." + ) + elif user.is_representative is True: + raise GraphQLError( + "This user cannot create a new working group, since they are already the representative of another working group." + ) + new_workinggroup = WorkingGroup( + name=input.name, + institution=institution, + representative=user, + field=research_field, + n_employees=input.n_employees, + is_public=input.is_public + ) + new_workinggroup.save() + + user.working_group = new_workinggroup + user.is_representative = True + user.save() + + return CreateWorkingGroup(success=success, workinggroup=new_workinggroup) + +class LeaveWorkingGroup(graphene.Mutation): + """Mutation to create a new working group""" + + success = graphene.Boolean() + + @staticmethod + @login_required + def mutate(root, info): + user = info.context.user + + if user.is_representative is True: + raise GraphQLError( + "Users that are representatives can not leave their working groups. Please delete the working group instead." + ) + + try: + setattr(user, "working_group", None) + user.save() + return LeaveWorkingGroup(success=True) + except ValidationError as e: + return LeaveWorkingGroup(success=False, errors=e) +class DeleteWorkingGroup(graphene.Mutation): + """Mutatino to delete an existing working group""" + + class Arguments: + """Assign input type""" + input = DeleteWorkingGroupInput() + + success = graphene.Boolean() + + @staticmethod + @login_required + @representative_required + def mutate(root, info, input=None): + + user = info.context.user + + working_groups = WorkingGroup.objects.filter( + id=input.id + ) + + if len(working_groups) == 0: + raise GraphQLError("Working group not found.") + if len(working_groups) > 1: + raise GraphQLError("More then one working group found, invalid ID") + + group_to_delete = working_groups[0] + + if info.context.user.id != group_to_delete.representative.id: + raise GraphQLError("You are not the representative of the specified working group. Unable to delete") + + try: + setattr(user, "is_representative", False) + user.save() + working_groups[0].delete() + return DeleteWorkingGroup(success=True) + except ValidationError as e: + return DeleteWorkingGroup(success=False, errors=e) + + + +class SetWorkingGroup(graphene.Mutation): + """GraphQL mutation to set working group of user""" + + class Arguments: + """Assign input type""" + + input = SetWorkingGroupInput() + + success = graphene.Boolean() + user = graphene.Field(UserType) + + @staticmethod + @login_required + def mutate(root, info, input=None): + """Process incoming data""" + user = info.context.user + success = True + + # Search matching working groups + matching_working_groups = WorkingGroup.objects.filter( + id=input.id + ) + if len(matching_working_groups) == 0: + raise GraphQLError("Working group not found.") + else: + working_group = matching_working_groups[0] + + setattr(user, "working_group", working_group) + user.save() + + try: + user.full_clean() + user.save() + return SetWorkingGroup(user=user, success=success) + except ValidationError as e: + return SetWorkingGroup(user=user, success=success, errors=e) + + +class RemoveUserFromWorkingGroup(graphene.Mutation): + """GraphQL mutation to remove a user from a working group""" + + class Arguments: + """Input structure defined by input type""" + + input = RemoveUserFromWorkingGroupInput() + + success = graphene.Boolean() + + @staticmethod + @login_required + @representative_required + def mutate(root, info, input): + """Process incoming data""" + user = info.context.user + + user_subset = CustomUser.objects.filter(id=input.user_id) + + if len(user_subset) == 0: + raise GraphQLError( + "The user you were trying to remove was not found. Please contact your administrator." + ) + else: + user_to_remove = user_subset[0] + + if user_to_remove.is_representative is True: + raise GraphQLError( + "Users that are representatives of the working group can not be removed from the groups." + ) + + if user_to_remove.working_group != user.working_group: + raise GraphQLError( + "The user you are trying to remove is not part of your working group!" + ) + + try: + setattr(user_to_remove, "working_group", None) + user_to_remove.save() + return RemoveUserFromWorkingGroup(success=True) + except ValidationError as e: + return RemoveUserFromWorkingGroup(success=False, errors=e) + + +class AddUserToWorkingGroup(graphene.Mutation): + """GraphQL mutaiton to add a user to a working group""" + + class Arguments: + """Input strucutred defined by input type""" + + input = AddUserToWorkingGroupInput() + + success= graphene.Boolean() + + @staticmethod + @login_required + @representative_required + def mutate(root, info, input): + """Process incoming data""" + + user = info.context.user + + working_group_set = WorkingGroup.objects.filter(id=user.working_group.id) + + if len(working_group_set) == 0: + raise GraphQLError("You are not part of a working group. You can only add users to your own working group.") + else: + working_group = working_group_set[0] + if info.context.user.id != working_group.representative.id: + raise GraphQLError("You are not the representative of the specified working group. Unable to delete") + + user_to_add_query_set = CustomUser.objects.filter(email=input.user_email) + + if len(user_to_add_query_set) > 1: + raise GraphQLError(f"Invalid email address input. Multiple users found.") # this should never happen as email addresses should be unique in the database + elif len(user_to_add_query_set) == 0: + raise GraphQLError(f"The user that you are trying to add is not registered.") + else: + user_to_add = user_to_add_query_set[0] + if user_to_add.working_group != None: + raise GraphQLError(f"The user you are trying to add already has a working group assigned.") + + # assign working group to user + try: + setattr(user_to_add, "working_group", working_group) + user_to_add.save() + return AddUserToWorkingGroup(success=True) + except ValidationError as e: + return AddUserToWorkingGroup(success=False, errors=e) + + +class AnswerJoinRequest(graphene.Mutation): + """GraphQL mutation to set working group of user""" + + class Arguments: + """Assign input type""" + + input = AnswerJoinRequestInput() + + success = graphene.Boolean() + requesting_user = graphene.Field(UserType) + + @staticmethod + @login_required + @representative_required + def mutate(root, info, input: AnswerJoinRequestInput = None): + """Process incoming data""" + success = True + user = info.context.user + + # Search for join request + matching_join_request = WorkingGroupJoinRequest.objects.filter(id=input.request_id) + if len(matching_join_request) == 0: + raise GraphQLError("Join request not found.") + else: + join_request = matching_join_request[0] + + # Check if the current user is the representative of the working group + if not user.working_group == join_request.working_group: + raise GraphQLError(f"You are not authorized to answer this join request, because you are " + f"not the representative of the {join_request.working_group.name}.") + + if not input.approve: + requesting_user = join_request.user + join_request.status = 'Declined' + join_request.save() + return AnswerJoinRequest(success=True, requesting_user=requesting_user) + elif input.approve: + requesting_user = join_request.user + setattr(join_request.user, "working_group", join_request.working_group) + requesting_user.save() + join_request.status = 'Approved' + join_request.save() + + try: + requesting_user.full_clean() + requesting_user.save() + return AnswerJoinRequest(success=success, requesting_user=requesting_user) + except ValidationError as e: + return SetWorkingGroup(success=success, requesting_user=None, errors=e) + + +class RequestJoinWorkingGroup(graphene.Mutation): + """GraphQL mutation to request to join a working group""" + + class Arguments: + """Assign input type""" + + input = JoinRequestInput() + + success = graphene.Boolean() + join_request = graphene.Field(WorkingGroupJoinRequestType) + + @staticmethod + @login_required + def mutate(root, info, input=None): + """Process incoming data""" + user = info.context.user + success = True + + # Search matching working groups + matching_working_groups = WorkingGroup.objects.filter( + id=input.workinggroup_id + ) + if len(matching_working_groups) == 0: + raise GraphQLError("Working group not found.") + else: + working_group = matching_working_groups[0] + + # Create entry in workinggroup join requests tabel + new_request = WorkingGroupJoinRequest(user=user, + working_group=working_group, + status='Pending') + new_request.save() + + # Send email to group representative + representative = working_group.representative + values = {'representative_first_name': representative.first_name, + 'representative_last_name': representative.last_name, + 'user_first_name': user.first_name, + 'user_last_name': user.last_name, + 'working_group_name': working_group.name, + 'path': os.getenv("PUBLIC_URL", "https://localhost") + "/working-group-details" + } + TEMPLATE_DIR = settings.TEMPLATES[0]['DIRS'][0] + email_client = EmailClient(template_dir=TEMPLATE_DIR) + text, html = email_client.get_template_email('join_request', values) + subject = email_client.get_template_subject('join_request', values) + email_client.send_email(subject, + html, + from_email="no-reply@pledge4future.org", + to_email=representative.email) + + + return RequestJoinWorkingGroup(success=success, join_request=new_request) class CreateElectricity(graphene.Mutation): + """GraphQL mutation for electricity""" + class Arguments: + """Assign input type""" + input = ElectricityInput(required=True) - ok = graphene.Boolean() + success = graphene.Boolean() electricity = graphene.Field(ElectricityType) @staticmethod + @login_required + @representative_required def mutate(root, info, input=None): - ok = True - matches = WorkingGroup.objects.filter(group_id=input.group_id) - if len(matches) == 0: - raise GraphQLError(f"Permission denied: Could add electricity data, because user '{input.username}' " - f"is not a group representative.") - else: - working_group = matches[0] + """Process incoming data""" + user = info.context.user + success = True + if input.fuel_type: + input.fuel_type = input.fuel_type.lower().replace(" ", "_") # Calculate co2 - co2e = calc_co2_electricity(input.consumption, input.fuel_type, input.group_share) - new_electricity = Electricity(working_group=working_group, - timestamp=input.timestamp, - consumption=input.consumption, - fuel_type=input.fuel_type, - group_share=input.group_share, - building=input.building, - co2e=round(co2e, 1)) + co2e = calc_co2_electricity( + input.consumption, + input.fuel_type, + input.group_share, + ) + + co2e_cap = co2e / user.working_group.n_employees + + # Store in database + new_electricity = Electricity( + working_group=user.working_group, + timestamp=input.timestamp, + consumption=input.consumption, + fuel_type=ElectricityFuel[input.fuel_type.upper()].name, + group_share=input.group_share, + building=input.building, + co2e=round(co2e, 1), + co2e_cap=round(co2e_cap, 1), + ) new_electricity.save() - return CreateElectricity(ok=ok, electricity=new_electricity) + return CreateElectricity(success=success, electricity=new_electricity) class CreateHeating(graphene.Mutation): + """GraphQL mutation for heating""" + class Arguments: + """Assign input type""" + input = HeatingInput(required=True) - ok = graphene.Boolean() + success = graphene.Boolean() heating = graphene.Field(HeatingType) @staticmethod + @login_required + @representative_required def mutate(root, info, input=None): - ok = True - matches = WorkingGroup.objects.filter(group_id=input.group_id) - if len(matches) == 0: - raise GraphQLError(f"Permission denied: Could add electricity data, because user '{input.username}' " - f"is not a group representative.") - else: - working_group = matches[0] - - # calculate co2 - co2e = calc_co2_heating(input.consumption, input.unit, input.fuel_type, input.group_share) - new_heating = Heating(working_group=working_group, - timestamp=input.timestamp, - consumption=input.consumption, - fuel_type=input.fuel_type, - building=input.building, - group_share=input.group_share, - co2e=round(co2e, 1)) + """Process incoming data""" + success = True + user = info.context.user + + # Calculate co2e + co2e = calc_co2_heating( + consumption=input.consumption, + unit=input.unit.lower().replace(" ", "_"), + fuel_type=input.fuel_type.lower().replace(" ", "_"), + area_share=input.group_share, + ) + co2e_cap = co2e / user.working_group.n_employees + + # Store in database + new_heating = Heating( + working_group=user.working_group, + timestamp=input.timestamp, + consumption=input.consumption, + fuel_type=input.fuel_type.upper().replace(" ", "_"), + unit=input.unit.lower().replace(" ", "_"), + building=input.building, + group_share=input.group_share, + co2e=round(co2e, 1), + co2e_cap=round(co2e_cap, 1), + ) new_heating.save() - return CreateHeating(ok=ok, heating=new_heating) + return CreateHeating(success=success, heating=new_heating) + + +class PlanTrip(graphene.Mutation): + """GraphQL mutation for business trips""" + + class Arguments: + """Assign input type""" + + input = PlanTripInput(required=True) + + success = graphene.Boolean() + co2e = graphene.Float() + message = graphene.String() + + @staticmethod + def mutate(root, info, input=None): + """Process incoming data""" + success = True + message = "success" + if input.seating_class: + input.seating_class = input.seating_class.lower().replace(" ", "_") + if input.fuel_type: + input.fuel_type = input.fuel_type.lower().replace(" ", "_") + if input.size: + input.size = input.size.lower().replace(" ", "_") + if input.transportation_mode: + input.transportation_mode = input.transportation_mode.lower().replace( + " ", "_" + ) + # CO2e calculation + start_dict = {"address": input.start_address, + "locality": input.start_city, + "country": input.start_country} + destination_dict = {"address": input.destination_address, + "locality": input.destination_city, + "country": input.destination_country} + + try: + co2e, distance, range_category, _ = calc_co2_businesstrip( + start=start_dict, + destination=destination_dict, + distance=input.distance, + transportation_mode=input.transportation_mode, + size=input.size, + fuel_type=input.fuel_type, + occupancy=input.occupancy, + seating=input.seating_class, + passengers=input.passengers, + roundtrip=input.roundtrip, + ) + print(co2e, distance, range_category, _) + except Exception as e: + traceback.print_exc() + return PlanTrip(success=False, message=str(e), co2e=None) + except RuntimeWarning as e: + message = e + return PlanTrip(success=success, message=message, co2e=co2e) + class CreateBusinessTrip(graphene.Mutation): + """GraphQL mutation for business trips""" + class Arguments: + """Assign input type""" + input = BusinessTripInput(required=True) - ok = graphene.Boolean() - #businesstrip = graphene.Field(BusinessTripType) + success = graphene.Boolean() + businesstrip = graphene.Field(BusinessTripType) @staticmethod + @login_required def mutate(root, info, input=None): - ok = True - user = User.objects.filter(username=input.username) - if len(user) == 0: - print("{} user not found".format(input.username)) - + """Process incoming data""" + success = True + user = info.context.user + if input.seating_class: + input.seating_class = input.seating_class.lower().replace(" ", "_") + if input.fuel_type: + input.fuel_type = input.fuel_type.lower().replace(" ", "_") + if input.size: + input.size = input.size.lower().replace(" ", "_") + if input.transportation_mode: + input.transportation_mode = input.transportation_mode.lower().replace( + " ", "_" + ) + # CO2e calculation co2e, distance, range_category, _ = calc_co2_businesstrip( start=input.start, destination=input.destination, @@ -446,88 +1309,123 @@ def mutate(root, info, input=None): occupancy=input.occupancy, seating=input.seating_class, passengers=input.passengers, - roundtrip=input.roundtrip) - businesstrip_instance = BusinessTrip(timestamp=input.timestamp, - distance=distance, - range_category=range_category, - transportation_mode=input.transportation_mode, - co2e=co2e, - user=user[0], - working_group=user[0].working_group) + roundtrip=input.roundtrip, + ) + # Write data to database + businesstrip_instance = BusinessTrip( + timestamp=input.timestamp, + distance=distance, + range_category=range_category, + transportation_mode=input.transportation_mode, + co2e=co2e, + user=user, + working_group=user.working_group, + ) businesstrip_instance.save() - return CreateBusinessTrip(ok=ok) + return CreateBusinessTrip(success=success, businesstrip=businesstrip_instance) class CreateCommuting(graphene.Mutation): + """GraphQL mutation for commuting""" + class Arguments: + """Assign input type""" + input = CommutingInput(required=True) - ok = graphene.Boolean() - #commute = graphene.Field(CommutingType) + success = graphene.Boolean() + # commute = graphene.Field(CommutingType) @staticmethod + @login_required def mutate(root, info, input=None): - ok = True - user = User.objects.filter(username=input.username) - if len(user) == 0: - raise GraphQLError(f"{input.username} user not found") - user = user[0] + """Process incoming data""" + success = True + user = info.context.user if input.workweeks is None: input.workweeks = WEEKS_PER_YEAR - - # calculate co2 - weekly_co2e = calc_co2_commuting(transportation_mode=input.transportation_mode, - weekly_distance=input.distance, - size=input.size, - fuel_type=input.fuel_type, - occupancy=input.occupancy, - passengers=input.passengers - ) + if input.transportation_mode: + input.transportation_mode = input.transportation_mode.lower().replace( + " ", "_" + ) + if input.fuel_type: + input.fuel_type = input.fuel_type.lower().replace(" ", "_") + if input.size: + input.size = input.size.lower().replace(" ", "_") + # Calculate co2 + weekly_co2e = calc_co2_commuting( + transportation_mode=input.transportation_mode, + weekly_distance=input.distance, + size=input.size, + fuel_type=input.fuel_type, + occupancy=input.occupancy, + passengers=input.passengers, + ) # Calculate monthly co2 - monthly_co2e = WEEKS_PER_MONTH * (input.workweeks / WEEKS_PER_YEAR) * weekly_co2e - dates = np.arange(np.datetime64(input.from_timestamp, "M"), - np.datetime64(input.to_timestamp, "M") + np.timedelta64(1, 'M'), - np.timedelta64(1, "M")).astype('datetime64[D]') + monthly_co2e = ( + WEEKS_PER_MONTH * (input.workweeks / WEEKS_PER_YEAR) * weekly_co2e + ) + dates = np.arange( + np.datetime64(input.from_timestamp, "M"), + np.datetime64(input.to_timestamp, "M") + np.timedelta64(1, "M"), + np.timedelta64(1, "M"), + ).astype("datetime64[D]") for d in dates: - commuting_instance = Commuting(timestamp=str(d), - distance=input.distance, - transportation_mode=input.transportation_mode, - co2e=monthly_co2e, - user=user, - working_group=user.working_group) + commuting_instance = Commuting( + timestamp=str(d), + distance=input.distance, + transportation_mode=input.transportation_mode, + co2e=monthly_co2e, + user=user, + working_group=user.working_group, + ) commuting_instance.save() # Update emissions of working group for date and transportation mode - entries = Commuting.objects.filter(working_group=user.working_group, - transportation_mode=input.transportation_mode, - timestamp=str(d)) - metrics = { - "co2e": Sum("co2e"), - "distance": Sum("distance") - } + if user.working_group is None: + continue + + entries = Commuting.objects.filter( + working_group=user.working_group, + transportation_mode=input.transportation_mode, + timestamp=str(d), + ) + metrics = {"co2e": Sum("co2e"), "distance": Sum("distance")} group_data = entries.aggregate(**metrics) co2e_cap = group_data["co2e"] / user.working_group.n_employees - commuting_group_instance = CommutingGroup(working_group=user.working_group, - timestamp=str(d), - transportation_mode=input.transportation_mode, - n_employees=user.working_group.n_employees, - co2e=group_data["co2e"], - co2e_cap=co2e_cap, - distance=group_data["distance"]) + commuting_group_instance = CommutingGroup( + working_group=user.working_group, + timestamp=str(d), + transportation_mode=input.transportation_mode, + n_employees=user.working_group.n_employees, + co2e=group_data["co2e"], + co2e_cap=co2e_cap, + distance=group_data["distance"], + ) commuting_group_instance.save() - return CreateCommuting(ok=ok) - + return CreateCommuting(success=success) class Mutation(AuthMutation, graphene.ObjectType): + """GraphQL Mutations""" + create_businesstrip = CreateBusinessTrip.Field() create_electricity = CreateElectricity.Field() create_heating = CreateHeating.Field() create_commuting = CreateCommuting.Field() - - -schema = graphene.Schema(query=Query, mutation=Mutation) \ No newline at end of file + request_join_working_group = RequestJoinWorkingGroup.Field() + set_working_group = SetWorkingGroup.Field() + remove_user_from_working_group = RemoveUserFromWorkingGroup.Field() + add_user_to_working_group = AddUserToWorkingGroup.Field() + delete_working_group = DeleteWorkingGroup.Field() + create_working_group = CreateWorkingGroup.Field() + leave_working_group = LeaveWorkingGroup.Field() + plan_trip = PlanTrip.Field() + answer_join_request = AnswerJoinRequest.Field() + + +schema = graphene.Schema(query=Query, mutation=Mutation) diff --git a/backend/src/emissions/signals.py b/backend/src/emissions/signals.py new file mode 100644 index 00000000..490c31ec --- /dev/null +++ b/backend/src/emissions/signals.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Functions to catch signals when models are being edited""" + +from django.dispatch import receiver +from django.db.models.signals import pre_save + +from emissions.models import (CustomUser, WorkingGroup) + +import logging + +logger = logging.getLogger(__name__) + + +@receiver(pre_save, sender=CustomUser) +def set_id_as_username(sender, instance, **kwargs): + """Sets the id of the user as its username since django.contrib.auth.models.AbstractUser requires one.""" + if not instance.username: + instance.username = instance.id + print(instance.username) + + +@receiver(pre_save, sender=WorkingGroup) +def update_representatives(sender, instance, **kwargs): + """Updates the info of the old and new representatives of the working group""" + # Update old representative + try: + old_instance = WorkingGroup.objects.get(id=instance.id) + old_representative = old_instance.representative + old_representative.is_representative = False + old_representative.save() + except WorkingGroup.DoesNotExist: + logger.info("Group does not exist yet") + + # Update new representative + new_representative = instance.representative + new_representative.is_representative = True + new_representative.save() + + diff --git a/backend/src/emissions/tests.py b/backend/src/emissions/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/backend/src/emissions/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/backend/src/emissions/tests/__init__.py b/backend/src/emissions/tests/__init__.py new file mode 100644 index 00000000..b40a7755 --- /dev/null +++ b/backend/src/emissions/tests/__init__.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""init tests""" diff --git a/backend/src/emissions/tests/conftest.py b/backend/src/emissions/tests/conftest.py new file mode 100644 index 00000000..b47bd05c --- /dev/null +++ b/backend/src/emissions/tests/conftest.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Conftest for shared pytest fixtures""" + +import logging +from pathlib import Path + +import requests +import pytest +import os +from dotenv import load_dotenv +import json + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) +logger.addHandler(logging.StreamHandler()) + +# Load settings from ./.env file +load_dotenv("../../../../.env") +GRAPHQL_URL = os.environ.get("GRAPHQL_URL") +logger.info(GRAPHQL_URL) + +with open("../data/test_data.json") as f: + test_data_users = json.load(f)["users"] + + +@pytest.fixture(scope="session") +def test_user1_token(): + """Log in test user and yield token""" + + query = """ + mutation ($email: String!, $password: String!){ + tokenAuth ( + email: $email + password: $password + ) { + success + errors + token + refreshToken + } + } + """ + variables = {"email": test_data_users["test_user1"]["email"], + "password": test_data_users["test_user1"]["password"] + } + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}) + assert response.status_code == 200 + data = response.json() + assert data["data"]["tokenAuth"]["success"] + + yield data["data"]["tokenAuth"]["token"] + + + +@pytest.fixture(scope="session") +def test_user2_token(): + """Log in test user and yield token""" + + query = """ + mutation ($email: String!, $password: String!){ + tokenAuth ( + email: $email + password: $password + ) { + success + errors + token + refreshToken + } + } + """ + variables = {"email": test_data_users["test_user2"]["email"], + "password": test_data_users["test_user2"]["password"] + } + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}) + assert response.status_code == 200 + data = response.json() + assert data["data"]["tokenAuth"]["success"] + + yield data["data"]["tokenAuth"]["token"] + + + +@pytest.fixture(scope="session") +def test_user3_rep_token(): + """Log in test user and yield token""" + + query = """ + mutation ($email: String!, $password: String!){ + tokenAuth ( + email: $email + password: $password + ) { + success + errors + token + refreshToken + } + } + """ + variables = {"email": test_data_users["test_user3_representative"]["email"], + "password": test_data_users["test_user3_representative"]["password"] + } + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}) + assert response.status_code == 200 + data = response.json() + assert data["data"]["tokenAuth"]["success"] + + yield data["data"]["tokenAuth"]["token"] + + + + +@pytest.fixture(scope="session") +def test_user4_rep_token(): + """Log in test user and yield token""" + + query = """ + mutation ($email: String!, $password: String!){ + tokenAuth ( + email: $email + password: $password + ) { + success + errors + token + refreshToken + } + } + """ + variables = {"email": test_data_users["test_user4_representative"]["email"], + "password": test_data_users["test_user4_representative"]["password"] + } + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}) + assert response.status_code == 200 + data = response.json() + assert data["data"]["tokenAuth"]["success"] + + yield data["data"]["tokenAuth"]["token"] + + diff --git a/backend/src/emissions/tests/test_add_data.py b/backend/src/emissions/tests/test_add_data.py new file mode 100644 index 00000000..dedd597e --- /dev/null +++ b/backend/src/emissions/tests/test_add_data.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Testing of GraphQL API queries related to adding data by user""" + +import requests +import logging +from dotenv import load_dotenv +import os +import json + + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) +logger.addHandler(logging.StreamHandler()) + +# Load settings from ./.env file +load_dotenv("../../../../.env") +GRAPHQL_URL = os.environ.get("GRAPHQL_URL") +logger.info(GRAPHQL_URL) + +with open("../data/test_data.json") as f: + test_data_users = json.load(f)["users"] + + +def test_add_electricity_data_not_representative(test_user1_token): + """Add electricity data by authenticated user""" + query = """ + mutation { + createElectricity (input: { + timestamp: "2020-10-01" + consumption: 3000 + fuelType: "solar" + building: "348" + groupShare: 1 + }) { + success + electricity { + timestamp + consumption + building + fuelType + co2e + } + } + } + """ + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user1_token}", + } + response = requests.post(GRAPHQL_URL, json={"query": query}, headers=headers) + assert response.status_code == 200 + data = response.json() + logger.warning(data) + assert ( + data["errors"][0]["message"] + == "Only group representatives have permission to perform this action." + ) + + +def test_add_electricity_data(test_user3_rep_token): + """Add electricity data by authenticated group representative""" + query = """ + mutation { + createElectricity (input: { + timestamp: "2020-12-01" + consumption: 3000 + fuelType: "Solar" + building: "348" + groupShare: 1 + }) { + success + electricity { + timestamp + consumption + building + fuelType + co2e + } + } + } + """ + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user3_rep_token}", + } + response = requests.post(GRAPHQL_URL, json={"query": query}, headers=headers) + assert response.status_code == 200 + data = response.json() + logger.warning(data) + assert data["data"]["createElectricity"]["success"] + assert data["data"]["createElectricity"]["electricity"]["consumption"] == 3000.0 + + +def test_add_heating_data(test_user3_rep_token): + """Add heating data by authenticated group representative""" + query = """ + mutation createHeating{ + createHeating (input: { + building: "348" + timestamp: "2022-10-01" + consumption: 3000 + unit: "l" + fuelType: "Oil" + groupShare: 1 + }) { + success + heating { + timestamp + consumption + fuelType + co2e + } + } + } + """ + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user3_rep_token}", + } + response = requests.post(GRAPHQL_URL, json={"query": query}, headers=headers) + logger.warning(response.content) + assert response.status_code == 200 + data = response.json() + # logger.warning(data) + assert data["data"]["createHeating"]["success"] + assert data["data"]["createHeating"]["heating"]["consumption"] == 3000.0 + + +def test_add_businesstrip_data(test_user1_token): + """Add businesstrip data by authenticated user""" + query = """ + mutation { + createBusinesstrip (input: { + timestamp: "2020-01-01" + transportationMode: "Car" + distance: 200 + size: "Medium" + fuelType: "Gasoline" + passengers: 1 + roundtrip: false + }) { + success + businesstrip { + distance + } + } + } + """ + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user1_token}", + } + response = requests.post(GRAPHQL_URL, json={"query": query}, headers=headers) + logger.warning(response.content) + assert response.status_code == 200 + data = response.json() + logger.warning(data) + assert data["data"]["createBusinesstrip"]["success"] + assert data["data"]["createBusinesstrip"]["businesstrip"]["distance"] == 200.0 + + +def test_add_commuting_data(test_user1_token): + """Add commuting data by authenticated user""" + query = """ + mutation createCommuting { + createCommuting (input: { + transportationMode: "Car" + distance: 30 + fromTimestamp: "2017-01-01" + toTimestamp: "2017-06-01" + fuelType: "Gasoline" + size: "Medium" + passengers: 1 + workweeks: 40 + }) { + success + } + } + """ + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user1_token}", + } + response = requests.post(GRAPHQL_URL, json={"query": query}, headers=headers) + logger.warning(response.content) + assert response.status_code == 200 + data = response.json() + logger.warning(data) + assert data["data"]["createCommuting"]["success"] diff --git a/backend/src/emissions/tests/test_authentication.py b/backend/src/emissions/tests/test_authentication.py new file mode 100644 index 00000000..31d46943 --- /dev/null +++ b/backend/src/emissions/tests/test_authentication.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Testing of GraphQL API queries related to user authentication""" +import logging +import os +import requests +from dotenv import load_dotenv, find_dotenv +import json + +logger = logging.getLogger(__file__) + +# Load settings from ./.env file +load_dotenv(find_dotenv()) + +GRAPHQL_URL = os.environ.get("GRAPHQL_URL") +TEST_EMAIL = os.environ.get("TEST_EMAIL") +TEST_PASSWORD = os.environ.get("TEST_PASSWORD") +TOKEN = "" +REFRESH_TOKEN = "" + +with open("../data/test_data.json") as f: + test_data_users = json.load(f)["users"] + + +def test_login(test_user1_token): + """Test user login""" + query = """ + mutation ($email: String!, $password: String!){ + tokenAuth ( + email: $email + password: $password + ) { + success + errors + token + refreshToken + } + } + """ + variables = {"email": test_data_users["test_user1"]["email"], + "password": test_data_users["test_user1"]["password"]} + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}) + assert response.status_code == 200 + data = response.json() + assert data["data"]["tokenAuth"]["success"] + + assert data["data"]["tokenAuth"]["token"] == test_user1_token + global REFRESH_TOKEN + REFRESH_TOKEN = data["data"]["tokenAuth"]["refreshToken"] + + +def test_verify_token(test_user1_token): + """Test if token can be verified""" + verify_token_query = """ + mutation ($token: String!){ + verifyToken( + token: $token + ) { + success, + errors, + payload + } + } + """ + variables = {"token": test_user1_token} + response = requests.post( + GRAPHQL_URL, json={"query": verify_token_query, "variables": variables} + ) + assert response.status_code == 200 + data = response.json() + assert data["data"]["verifyToken"]["success"] + + +def test_me_query(test_user3_rep_token): + """Test whether me query returns the currently logged in user""" + me_query = """ + query { + me { + verified + firstName + workingGroup { + id + name + } + } + } + """ + headers = {"Content-Type": "application/json", "Authorization": f"JWT {test_user3_rep_token}"} + response = requests.post(GRAPHQL_URL, json={"query": me_query}, headers=headers) + assert response.status_code == 200 + data = response.json() + assert data["data"]["me"]["firstName"] == test_data_users["test_user3_representative"]["first_name"] + assert data["data"]["me"]["verified"] + assert data["data"]["me"]["workingGroup"] is not None + + +def test_update_query(test_user1_token): + """Test whether user data can be updated""" + update_query = """ + mutation { + updateAccount ( + firstName: "Louise" + lastName: "Ise" + ) { + success + errors + } + } + """ + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user1_token}", + } + response = requests.post(GRAPHQL_URL, json={"query": update_query}, headers=headers) + assert response.status_code == 200 + data = response.json() + assert data["data"]["updateAccount"]["success"] + + # reset user data + update_query = """ + mutation ($first_name: String!, $last_name: String!) { + updateAccount ( + firstName: $first_name + lastName: $last_name + ) { + success + errors + } + } + """ + variables = { + "first_name": test_data_users["test_user1"]["first_name"], + "last_name": test_data_users["test_user1"]["last_name"], + } + response = requests.post(GRAPHQL_URL, json={"query": update_query, "variables": variables}, headers=headers) + assert response.status_code == 200 + + +def test_change_password(test_user1_token): + """Test whether password can be changed""" + new_password = "123456super" + change_password_query = """ + mutation ($password: String!, $new_password: String!) { + passwordChange( + oldPassword: $password, + newPassword1: $new_password, + newPassword2: $new_password + ) { + success, + errors, + token, + refreshToken + } + }""" + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user1_token}", + } + variables = { + "password": test_data_users["test_user1"]["password"], + "new_password": new_password + } + response = requests.post( + GRAPHQL_URL, json={"query": change_password_query, "variables": variables}, headers=headers + ) + assert response.status_code == 200 + data = response.json() + assert data["data"]["passwordChange"]["success"] + global TOKEN + TOKEN = data["data"]["passwordChange"]["token"] + + # reset changes (cleanup) + change_back_password_query = """ + mutation ($password: String!, $new_password: String!) { + passwordChange( + oldPassword: $new_password, + newPassword1: $password, + newPassword2: $password + ) { + success, + errors, + token, + refreshToken + } + }""" + response = requests.post( + GRAPHQL_URL, json={"query": change_back_password_query, "variables": variables}, headers=headers + ) + assert response.status_code == 200 + + +def test_list_users(): + """Test to query all users""" + query = """ + query { + users { + edges { + node { + email + } + } + } + } + """ + response = requests.post(GRAPHQL_URL, json={"query": query}) + assert response.status_code == 200 + data = response.json() + assert len(data["data"]["users"]["edges"]) > 1 + + +def test_query_dropdown_options(): + """Test if querying dropdown options""" + query = """ + { __type(name: "ElectricityFuelType") { + enumValues { + name + description + } + } + } + """ + response = requests.post(GRAPHQL_URL, json={"query": query}) + assert response.status_code == 200 + data = response.json() + assert data["data"]["__type"]["enumValues"][0]["name"] == "GERMAN_ENERGY_MIX" + + query = """ + {__type(name: "Unit") { + enumValues + { + name + description + } + } + } + """ + response = requests.post(GRAPHQL_URL, json={"query": query}) + assert response.status_code == 200 + response.json() diff --git a/backend/src/emissions/tests/test_emailclient.py b/backend/src/emissions/tests/test_emailclient.py new file mode 100644 index 00000000..09db64cc --- /dev/null +++ b/backend/src/emissions/tests/test_emailclient.py @@ -0,0 +1,38 @@ +from dotenv import load_dotenv +load_dotenv("../../.env") +from backend.src.emissions.email_client import EmailClient +from django.conf import settings + +TEMPLATE_DIR = settings.TEMPLATES[0]['DIRS'][0] + + +def test_emailclient(): + """Tests whether setting up EmailClient works""" + client = EmailClient(template_dir=TEMPLATE_DIR) + assert isinstance(client, EmailClient) + + +def test_get_template_email(): + """Tests whether getting an email template works""" + client = EmailClient(template_dir=TEMPLATE_DIR) + email_text, html_text = client.get_template_email('join_request') + assert isinstance(email_text, str) + + +def test_get_template_subject(): + """Tests whether getting an email subject template works""" + client = EmailClient(template_dir=TEMPLATE_DIR) + email_text = client.get_template_subject('join_request') + assert isinstance(email_text, str) + + +def test_send_email(): + """Tests whether sending an email works""" + + client = EmailClient(template_dir=TEMPLATE_DIR) + from_email = "no-reply@pledge4future.org" + to_email = "christina_ludwig@gmx.net" + + _, html_text = client.get_template_email('join_request') + email_subject = client.get_template_subject('join_request') + client.send_email(email_subject, html_text, from_email, to_email) diff --git a/backend/src/emissions/tests/test_plantrip.py b/backend/src/emissions/tests/test_plantrip.py new file mode 100644 index 00000000..88a492aa --- /dev/null +++ b/backend/src/emissions/tests/test_plantrip.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""__description__""" + +__author__ = "Christina Ludwig, GIScience Research Group, Heidelberg University" +__email__ = "christina.ludwig@uni-heidelberg.de" + + +import pytest +import requests +from dotenv import load_dotenv +import os +import logging + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) +logger.addHandler(logging.StreamHandler()) + +# Load settings from ./.env file +load_dotenv("../../../../.env") +GRAPHQL_URL = os.environ.get("GRAPHQL_URL") +logger.info(GRAPHQL_URL) + +@pytest.mark.parametrize('transportation_mode', ['Car']) +@pytest.mark.parametrize('size', ['small', 'medium', 'large', 'average']) +@pytest.mark.parametrize('fuel_type', ['diesel', 'gasoline', 'cng', 'electric', 'hybrid', 'plug-in_hybrid', 'average']) +@pytest.mark.parametrize('passengers', [1]) +@pytest.mark.parametrize('round_trip', [False]) +def test_plan_trip_car(transportation_mode, size, fuel_type, passengers, round_trip): + """Test whether trip planner throws error for different parameter combinations for car trips""" + query = """ + mutation planTrip ($transportationMode: String!, $size: String!, $fuelType: String!, $passengers: Int!, $roundTrip: Boolean!) { + planTrip (input: { + transportationMode: $transportationMode + distance: 200 + size: $size + fuelType: $fuelType + passengers: $passengers + roundtrip: $roundTrip + }) { + success + message + co2e + } + } + """ + + headers = { + "Content-Type": "application/json", + } + variables = {"transportationMode": transportation_mode, "size": size, "fuelType": fuel_type, "passengers": passengers, "roundTrip": round_trip} + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers) + logger.warning(response.content) + assert response.status_code == 200 + data = response.json() + logger.warning(data) + assert data["data"]["planTrip"]["success"] + +@pytest.mark.parametrize('transportation_mode', ['Bus']) +@pytest.mark.parametrize('size', ['medium', 'large', 'average']) +@pytest.mark.parametrize('fuel_type', ['diesel']) +@pytest.mark.parametrize('occupancy', [20., 50., 80., 100.]) +@pytest.mark.parametrize('roundtrip', [True, False]) +def test_plan_trip_bus(transportation_mode, size, fuel_type, occupancy, roundtrip): + """Test whether trip planner throws error for different parameter combinations for bus trips""" + query = """ + mutation planTrip ($transportationMode: String!, $size: String!, $fuelType: String!, $occupancy: Float!, + $roundtrip: Boolean!) { + planTrip (input: { + transportationMode: $transportationMode + size: $size + fuelType: $fuelType + occupancy: $occupancy + roundtrip: $roundtrip + distance: 200.0 + }) { + success + message + co2e + } + } + """ + + headers = { + "Content-Type": "application/json", + } + variables = {"transportationMode": transportation_mode, "size": size, "fuelType": fuel_type, + "occupancy": occupancy, "roundtrip": roundtrip} + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers) + logger.warning(response.content) + assert response.status_code == 200 + data = response.json() + logger.warning(data) + assert data["data"]["planTrip"]["success"] + + +@pytest.mark.parametrize('transportation_mode', ['Train']) +@pytest.mark.parametrize('fuel_type', ['diesel', 'electric', 'average']) +@pytest.mark.parametrize('roundtrip', [True, False]) +def test_plan_trip_train(transportation_mode, fuel_type, roundtrip): + """Test whether trip planner throws error for different parameter combinations for train trips""" + query = """ + mutation planTrip ($transportationMode: String!, $fuelType: String!, $roundtrip: Boolean!) { + planTrip (input: { + transportationMode: $transportationMode + fuelType: $fuelType + roundtrip: $roundtrip + distance: 200 + }) { + success + message + co2e + } + } + """ + + headers = { + "Content-Type": "application/json", + } + variables = {"transportationMode": transportation_mode, "fuelType": fuel_type, "roundtrip": roundtrip} + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers) + logger.warning(response.content) + assert response.status_code == 200 + data = response.json() + logger.warning(data) + assert data["data"]["planTrip"]["success"] + + +@pytest.mark.parametrize('transportation_mode', ['Plane']) +@pytest.mark.parametrize('seating_class', ['average', 'Economy class', 'Premium economy class', 'Business class', 'First class']) +@pytest.mark.parametrize('roundtrip', [True, False]) +def test_plan_trip_plane(transportation_mode, seating_class, roundtrip): + """Test whether trip planner throws error for different parameter combinations for plane trips""" + query = """ + mutation planTrip ($transportationMode: String!, $seatingClass: String!, $roundtrip: Boolean!) { + planTrip (input: { + transportationMode: $transportationMode + seatingClass: $seatingClass + roundtrip: $roundtrip + distance: 500 + }) { + success + message + co2e + } + } + """ + + headers = { + "Content-Type": "application/json", + } + variables = {"transportationMode": transportation_mode, "seatingClass": seating_class, "roundtrip": roundtrip} + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers) + logger.warning(response.content) + assert response.status_code == 200 + data = response.json() + logger.warning(data) + assert data["data"]["planTrip"]["success"] + + +@pytest.mark.parametrize('transportation_mode', ['Ferry']) +@pytest.mark.parametrize('seating_class', ['average', 'Foot passenger', 'Car passenger']) +@pytest.mark.parametrize('roundtrip', [True, False]) +def test_plan_trip_ferry(transportation_mode, seating_class, roundtrip): + """Test whether trip planner throws error for different parameter combinations for ferry trips""" + query = """ + mutation planTrip ($transportationMode: String!, $seatingClass: String!, $roundtrip: Boolean!) { + planTrip (input: { + transportationMode: $transportationMode + seatingClass: $seatingClass + roundtrip: $roundtrip + distance: 200 + }) { + success + message + co2e + } + } + """ + + headers = { + "Content-Type": "application/json", + } + variables = {"transportationMode": transportation_mode, "seatingClass": seating_class, "roundtrip": roundtrip} + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers) + logger.warning(response.content) + assert response.status_code == 200 + data = response.json() + logger.warning(data) + assert data["data"]["planTrip"]["success"] \ No newline at end of file diff --git a/backend/src/emissions/tests/test_query_data.py b/backend/src/emissions/tests/test_query_data.py new file mode 100644 index 00000000..6f23c27c --- /dev/null +++ b/backend/src/emissions/tests/test_query_data.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Testing of GraphQL API queries related to querying data""" + +import requests +import logging +from dotenv import load_dotenv +import os + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) +logger.addHandler(logging.StreamHandler()) + +# Load settings from ./.env file +load_dotenv("../../../../.env") +GRAPHQL_URL = os.environ.get("GRAPHQL_URL") +logger.info(GRAPHQL_URL) + + +def test_query_heating_aggregated(test_user3_rep_token): + """Query aggregated heating data by authenticated user""" + query = """ + query ($level: String!) { + heatingAggregated (level: $level) { + date + co2e + co2eCap + } + } + """ + variables = {"level": "group"} + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user3_rep_token}", + } + response = requests.post( + GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers + ) + assert response.status_code == 200 + data = response.json() + assert isinstance(data["data"]["heatingAggregated"][0]["date"], str) + assert isinstance(data["data"]["heatingAggregated"][0]["co2e"], float) + assert isinstance(data["data"]["heatingAggregated"][0]["co2eCap"], float) + assert len(data["data"]["heatingAggregated"]) == 2 + + +def test_query_heating_aggregated_personal(test_user3_rep_token): + """Query aggregated heating data by authenticated user""" + query = """ + query ($level: String!) { + heatingAggregated (level: $level) { + date + co2e + co2eCap + } + } + """ + variables = {"level": "personal"} + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user3_rep_token}", + } + response = requests.post( + GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers + ) + assert response.status_code == 200 + data = response.json() + assert isinstance(data["data"]["heatingAggregated"][0]["date"], str) + assert isinstance(data["data"]["heatingAggregated"][0]["co2e"], float) + assert isinstance(data["data"]["heatingAggregated"][0]["co2eCap"], float) + assert len(data["data"]["heatingAggregated"]) == 2 + assert data["data"]["heatingAggregated"][0]["co2eCap"] == data["data"]["heatingAggregated"][0]["co2e"] + +def test_query_heating_aggregated_no_workinggroup(test_user1_token): + """Query aggregated heating data by authenticated user""" + query = """ + query ($level: String!) { + heatingAggregated (level: $level) { + date + co2e + co2eCap + } + } + """ + variables = {"level": "personal"} + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user1_token}", + } + response = requests.post( + GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers + ) + assert response.status_code == 200 + data = response.json() + assert data["errors"][0]["message"] == 'No heating data available, since user is not assigned to any working group yet.' + + +def test_query_electricity_aggregated_institution(test_user3_rep_token): + """Query aggregated electricity data by authenticated user""" + query = """ + query ($level: String!) { + electricityAggregated (level: $level) { + date + co2e + co2eCap + } + } + """ + variables = {"level": "institution"} + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user3_rep_token}", + } + response = requests.post( + GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers + ) + assert response.status_code == 200 + data = response.json() + assert isinstance(data["data"]["electricityAggregated"][0]["date"], str) + assert isinstance(data["data"]["electricityAggregated"][0]["co2e"], float) + assert isinstance(data["data"]["electricityAggregated"][0]["co2eCap"], float) + assert len(data["data"]["electricityAggregated"]) == 1 + + + +def test_query_electricity_aggregated_institution(test_user3_rep_token): + """Query aggregated electricity data by authenticated user""" + query = """ + query ($level: String!) { + electricityAggregated (level: $level) { + date + co2e + co2eCap + } + } + """ + variables = {"level": "institution"} + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user3_rep_token}", + } + response = requests.post( + GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers + ) + assert response.status_code == 200 + data = response.json() + assert isinstance(data["data"]["electricityAggregated"][0]["date"], str) + assert isinstance(data["data"]["electricityAggregated"][0]["co2e"], float) + assert isinstance(data["data"]["electricityAggregated"][0]["co2eCap"], float) + assert len(data["data"]["electricityAggregated"]) == 2 + + + +def test_query_businesstrip_aggregated_personal(test_user1_token): + """Query aggregated businesstrip data by authenticated user""" + query = """ + query ($level: String!) { + businesstripAggregated (level: $level) { + date + co2e + co2eCap + } + } + """ + variables = {"level": "personal"} + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user1_token}", + } + response = requests.post( + GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers + ) + assert response.status_code == 200 + data = response.json() + logger.warning(data) + assert isinstance(data["data"]["businesstripAggregated"][0]["date"], str) + assert isinstance(data["data"]["businesstripAggregated"][0]["co2e"], float) + assert isinstance(data["data"]["businesstripAggregated"][0]["co2eCap"], float) + + +def test_query_commuting_aggregated_group(test_user1_token): + """Query aggregated commuting data by authenticated user""" + query = """ + query ($level: String!) { + commutingAggregated (level: $level) { + date + co2e + co2eCap + } + } + """ + variables = {"level": "personal"} + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user1_token}", + } + response = requests.post( + GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers + ) + assert response.status_code == 200 + data = response.json() + logger.warning(data) + assert isinstance(data["data"]["commutingAggregated"][0]["date"], str) + assert isinstance(data["data"]["commutingAggregated"][0]["co2e"], float) + assert isinstance(data["data"]["commutingAggregated"][0]["co2eCap"], float) + + +def test_query_electricity_aggregated_with_invalid_token(): + """Query aggregated electricity data by non authenticated user should return an error message but no data""" + query = """ + query ($level: String!) { + electricityAggregated (level: $level) { + date + co2e + co2eCap + } + } + """ + variables = {"level": "institution"} + headers = { + "Content-Type": "application/json", + "Authorization": "JWT invalid_token", + } + response = requests.post( + GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers + ) + assert response.status_code == 200 + data = response.json() + assert ( + data["errors"][0]["message"] + == "You do not have permission to perform this action" + ) + + + + +def test_resolve_institutions(): + """List all institutions""" + query = """ + query { + institutions { + id + name + city + state + country + } + } + """ + response = requests.post(GRAPHQL_URL, json={"query": query}) + assert response.status_code == 200 + data = response.json() + # logger.warning(data) + assert len(data["data"]["institutions"]) > 0 + + +def test_resolve_research_fields(): + """List all research fields""" + query = """ + query { + researchfields { + field + subfield + } + } + """ + response = requests.post(GRAPHQL_URL, json={"query": query}) + assert response.status_code == 200 + data = response.json() + # logger.warning(data) + assert data["data"]["researchfields"][0]["field"] == "Natural Sciences" + diff --git a/backend/src/emissions/tests/test_working_groups.py b/backend/src/emissions/tests/test_working_groups.py new file mode 100644 index 00000000..1a94f433 --- /dev/null +++ b/backend/src/emissions/tests/test_working_groups.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Testing of GraphQL API queries related to management of working groups""" + +import requests +import logging +from dotenv import load_dotenv +import os +import json + + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) +logger.addHandler(logging.StreamHandler()) + +# Load settings from ./.env file +load_dotenv("../../../../.env") +GRAPHQL_URL = os.environ.get("GRAPHQL_URL") +logger.info(GRAPHQL_URL) + + +with open("../data/test_data.json") as f: + test_workinggroups = json.load(f)["working_groups"] + + +def test_set_workinggroup(test_user1_token): + """Test whether user data can be updated""" + query = """ + mutation ($id: String!){ + setWorkingGroup (input: { + id: $id + } + ) { + success + user { + email + workingGroup { + name + } + } + } + } + """ + variables = { + "id": test_workinggroups['working_group1']['id'] + } + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user1_token}", + } + response = requests.post( + GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers + ) + assert response.status_code == 200 + data = response.json() + logger.info(data["data"]) + assert data["data"]["setWorkingGroup"]["success"] + assert ( + data["data"]["setWorkingGroup"]["user"]["workingGroup"]["name"] + == test_workinggroups['working_group1']["name"] + ) + + +def test_resolve_working_groups(test_user1_token): + """List all working groups""" + query = """ + query { + workinggroups { + id + name + field { + field + subfield + } + } + } + """ + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user1_token}", + } + response = requests.post(GRAPHQL_URL, json={"query": query}, headers=headers) + assert response.status_code == 200 + data = response.json() + # logger.warning(data) + assert len(data["data"]["workinggroups"]) > 0 + + +def test_create_workinggroup(test_user2_token): + """Create a new working group""" + query = """ + mutation ($name: String!, $institution_id: String!, $research_field_id: Int!, $nemployees: Int!, $is_public: Boolean!){ + createWorkingGroup (input: { + name: $name + institutionId: $institution_id + researchFieldId: $research_field_id + nEmployees: $nemployees + isPublic: $is_public + }) { + success + workinggroup { + name + representative { + email + } + } + } + } + """ + variables = { + "name": test_workinggroups['working_group1']['name'] + "test", + "institution_id": test_workinggroups['working_group1']['institution']['id'], + "research_field_id": test_workinggroups['working_group1']['research_field']['id'], + "nemployees": test_workinggroups['working_group1']['n_employees'], + "is_public": test_workinggroups['working_group1']['is_public'], + } + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user2_token}", + } + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers) + assert response.status_code == 200 + logger.warning(response.content) + data = response.json() + logger.warning(data) + assert data["data"]["createWorkingGroup"]["success"] + assert ( + data["data"]["createWorkingGroup"]["workinggroup"]["representative"]["email"] is not None + ) + # todo: delete working group after it has been created + +def test_delete_workinggroup_by_representative(test_user2_token): + """Tests whether working groups can be deleted""" + query = """ + mutation ($name: String!, $institution_id: String!, $research_field_id: Int!, $nemployees: Int!, $is_public: Boolean!){ + createWorkingGroup (input: { + name: $name + institutionId: $institution_id + researchFieldId: $research_field_id + nEmployees: $nemployees + isPublic: $is_public + }) { + success + workinggroup { + name + representative { + email + } + } + } + } + """ + variables = { + "name": test_workinggroups['workinggroup_to_delete']['name'], + "institution_id": test_workinggroups['workinggroup_to_delete']['institution']['id'], + "research_field_id": test_workinggroups['workinggroup_to_delete']['research_field']['id'], + "nemployees": test_workinggroups['workinggroup_to_delete']['n_employees'], + "is_public": test_workinggroups['workinggroup_to_delete']['is_public'], + } + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user2_token}", + } + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers) + assert response.status_code == 200 + + workinggroup_id = test_workinggroups["workinggroup_to_delete"]["id"] + mutation = ''' + mutation ($workingGroupId: String!){ + deleteWorkingGroup (input: { + id: $workingGroupId + }) { + success + } + } + ''' + variables = { + "id": workinggroup_id + } + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers) + assert response.status_code == 200 + assert response.success == True + + + +def test_create_workinggroup_by_representative(test_user3_rep_token): + """Create a new working group""" + query = """ + mutation ($name: String!, $institution_id: String!, $research_field_id: Int!, $nemployees: Int!, $is_public: Boolean!){ + createWorkingGroup (input: { + name: $name + institutionId: $institution_id + researchFieldId: $research_field_id + nEmployees: $nemployees + isPublic: $is_public + }) { + success + workinggroup { + name + representative { + email + } + } + } + } + """ + variables = { + "name": test_workinggroups['working_group1']['name'] + "test2", + "institution_id": test_workinggroups['working_group1']['institution']['id'], + "research_field_id": test_workinggroups['working_group1']['research_field']['id'], + "nemployees": test_workinggroups['working_group1']['n_employees'], + "is_public": test_workinggroups['working_group1']['is_public'], + } + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user3_rep_token}", + } + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers) + assert response.status_code == 200 + logger.warning(response.content) + data = response.json() + assert ( + data["errors"][0]["message"] + == "This is wrong cannot create a new working group, since they are already the representative of another working group." + ) + + +def test_join_request_workinggroup(test_user1_token, test_user3_rep_token, test_user4_rep_token): + """Test whether user data can be updated""" + query = """ + mutation ($id: String!){ + requestJoinWorkingGroup (input: { + workinggroupId: $id + } + ) { + success + joinRequest { + status + id + workingGroup { + id + } + } + } + } + """ + variables = { + "id": test_workinggroups['working_group1']['id'] + } + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user1_token}", + } + response = requests.post( + GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers + ) + assert response.status_code == 200 + data = response.json() + assert data["data"]['requestJoinWorkingGroup']['joinRequest']["workingGroup"]["id"] == test_workinggroups['working_group1']['id'] + assert data["data"]['requestJoinWorkingGroup']['joinRequest']["status"] == 'PENDING' + request_id = data["data"]['requestJoinWorkingGroup']['joinRequest']['id'] + + # test if error is raised if person other than the group representative answers + query2 = """ + mutation ($requestId: String!, $approve: Boolean!){ + answerJoinRequest (input: { + approve: $approve + requestId: $requestId + } + ) { + success + requestingUser { + workingGroup { + id + } + } + } + } + """ + variables2 = { + "requestId": request_id, + "approve": True + } + headers2 = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user4_rep_token}", + } + response2 = requests.post( + GRAPHQL_URL, json={"query": query2, "variables": variables2}, headers=headers2 + ) + assert response2.status_code == 200 + data2 = response2.json() + assert ( + "You are not authorized to answer this join request" in data2["errors"][0]["message"] + ) + + # Test if request can be approved + query3 = """ + mutation ($requestId: String!, $approve: Boolean!){ + answerJoinRequest (input: { + approve: $approve + requestId: $requestId + } + ) { + success + requestingUser { + workingGroup { + id + } + } + } + } + """ + variables3 = { + "requestId": request_id, + "approve": True + } + headers3 = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user3_rep_token}", + } + response3 = requests.post( + GRAPHQL_URL, json={"query": query3, "variables": variables3}, headers=headers3 + ) + assert response3.status_code == 200 + data3 = response3.json() + assert data3['data']["answerJoinRequest"]['success'] + assert data3['data']["answerJoinRequest"]['requestingUser']['workingGroup']['id'] == test_workinggroups['working_group1']['id'] + diff --git a/backend/src/emissions/views.py b/backend/src/emissions/views.py index b91e46a5..b8005d8b 100644 --- a/backend/src/emissions/views.py +++ b/backend/src/emissions/views.py @@ -1,3 +1,7 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""init""" + from django.shortcuts import render -# Create your views here. \ No newline at end of file +# Create your views here. diff --git a/backend/src/entryPoint.sh b/backend/src/entryPoint.sh index 4ed33b89..5da7395b 100755 --- a/backend/src/entryPoint.sh +++ b/backend/src/entryPoint.sh @@ -1,6 +1,18 @@ #!/bin/bash +rm emissions/migrations/00*.py python manage.py makemigrations emissions python manage.py migrate -python manage.py create_groups -python manage.py populate_data -python manage.py runserver 0.0.0.0:8000 \ No newline at end of file +python manage.py load_institutions +python manage.py loaddata research_fields.json +python manage.py create_test_data +python manage.py graph_models emissions -a -o /home/python/app/assets/database_structure.png --pydot +#python manage.py populate_data +python manage.py collectstatic --noinput +python manage.py check --deploy +if [ "$DJANGO_DEBUG" = 'True' ] ; then + echo "Using development server" + python manage.py runserver 0.0.0.0:8000 +else + echo "Using production server" + gunicorn -b 0.0.0.0:8000 pledge4future.wsgi +fi diff --git a/backend/src/manage.py b/backend/src/manage.py index dd31b70f..53f8e1fc 100755 --- a/backend/src/manage.py +++ b/backend/src/manage.py @@ -1,12 +1,15 @@ #!/usr/bin/env python +# -*- coding: utf-8 -*- + """Django's command-line utility for administrative tasks.""" + import os import sys def main(): """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'wepledge.settings') + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pledge4future.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: @@ -18,5 +21,5 @@ def main(): execute_from_command_line(sys.argv) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/backend/src/pledge4future/__init__.py b/backend/src/pledge4future/__init__.py new file mode 100644 index 00000000..c2444f4b --- /dev/null +++ b/backend/src/pledge4future/__init__.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""__init__""" diff --git a/backend/src/wepledge/asgi.py b/backend/src/pledge4future/asgi.py similarity index 64% rename from backend/src/wepledge/asgi.py rename to backend/src/pledge4future/asgi.py index af40bfda..cf8641cb 100644 --- a/backend/src/wepledge/asgi.py +++ b/backend/src/pledge4future/asgi.py @@ -1,5 +1,8 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + """ -ASGI config for wepledge project. +ASGI config for pledge4future project. It exposes the ASGI callable as a module-level variable named ``application``. @@ -11,6 +14,6 @@ from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'wepledge.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pledge4future.settings") application = get_asgi_application() diff --git a/backend/src/pledge4future/settings.py b/backend/src/pledge4future/settings.py new file mode 100644 index 00000000..1e3cb8e3 --- /dev/null +++ b/backend/src/pledge4future/settings.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Django settings for pledg4future project. + +Generated by 'django-admin startproject' using Django 3.1.5. + +For more information on this file, see +https://docs.djangoproject.com/en/3.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.1/ref/settings/ +""" +from datetime import timedelta +from pathlib import Path +import os +from dotenv import load_dotenv, find_dotenv + +import django +from django.utils.encoding import force_str +django.utils.encoding.force_text = force_str + + +# Load settings from ./.env file +# load_dotenv("../../.env", verbose=True) +#load_dotenv(find_dotenv()) + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent +print(BASE_DIR) + +STATIC_ROOT = BASE_DIR.parent / "static" +MEDIA_ROOT = BASE_DIR.parent / "media" + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! - is set in local .env file +SECRET_KEY = os.environ.get("SECRET_KEY") + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = False #os.environ.get("DJANGO_DEBUG") + +ALLOWED_HOSTS = ["http://localhost", "localhost", "0.0.0.0", "http://api.test-pledge4future.heigit.org"] + +CORS_ALLOW_ALL_ORIGINS = False +CORS_ALLOW_CREDENTIALS = False +CORS_ALLOWED_ORIGINS = ["http://localhost:3000","https://test-pledge4future.heigit.org"] + +CSRF_COOKIE_SECURE=True +SESSION_COOKIE_SECURE=True +SECURE_HSTS_SECONDS=30 +SECURE_HSTS_INCLUDE_SUBDOMAINS=True + +# Application definition +INSTALLED_APPS = [ + 'emissions.apps.EmissionsConfig', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "graphene_django", + "graphql_jwt.refresh_token.apps.RefreshTokenConfig", + "graphql_auth", + "django_filters", + "django_extensions", +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.middleware.common.CommonMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware" +] + +ROOT_URLCONF = "pledge4future.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [os.path.join(BASE_DIR, "./templates")], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ] + }, + } +] + +WSGI_APPLICATION = "pledge4future.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/3.1/ref/settings/#databases + +# local database for development +# DATABASES = { +# "default": { +# "ENGINE": "django.db.backends.sqlite3", +# "NAME": os.path.join(BASE_DIR, "db.sqlitedb"), +# } +# } + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "postgres", + "USER": "postgres", + "HOST": "db", + "PORT": 5432, + } +} + +# Password validation +# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" + }, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +STATIC_URL = "/static/" + +AUTH_USER_MODEL = "emissions.CustomUser" + +GRAPHENE = { + "SCHEMA": "emissions.schema.schema", + "MIDDLEWARE": ["graphql_jwt.middleware.JSONWebTokenMiddleware"], +} + +AUTHENTICATION_BACKENDS = [ + "graphql_auth.backends.GraphQLAuthBackend", + "django.contrib.auth.backends.ModelBackend", +] + +GRAPHQL_JWT = { + # "JWT_AUTH_HEADER_NAME": "HTTP_Authorization", + "JWT_AUTH_HEADER_PREFIX": "JWT", + "JWT_VERIFY_EXPIRATION": True, + "JWT_EXPIRATION_DELTA": timedelta(hours=24), + "JWT_REFRESH_EXPIRATION_DELTA": timedelta(days=7), + "JWT_LONG_RUNNING_REFRESH_TOKEN": True, + "JWT_ALLOW_ANY_CLASSES": [ + "graphql_auth.mutations.Register", + "graphql_auth.mutations.VerifyAccount", + "graphql_auth.mutations.ResendActivationEmail", + "graphql_auth.mutations.SendPasswordResetEmail", + "graphql_auth.mutations.PasswordReset", + "graphql_auth.mutations.ObtainJSONWebToken", + "graphql_auth.mutations.VerifyToken", + "graphql_auth.mutations.RefreshToken", + "graphql_auth.mutations.RevokeToken", + "graphql_auth.mutations.VerifySecondaryEmail", + ], +} + +GRAPHQL_AUTH = { + "LOGIN_ALLOWED_FIELDS": ["email"], + "REGISTER_MUTATION_FIELDS": [ + "email", + "first_name", + "last_name", + ], + "REGISTER_MUTATION_FIELDS_OPTIONAL": ["academic_title", "first_name", "last_name"], + "UPDATE_MUTATION_FIELDS": [ + "first_name", + "last_name", + "academic_title" + ], # "is_representative", "working_group" - make separate mutation + "ALLOW_DELETE_ACCOUNT": True, + "SEND_ACTIVATION_EMAIL": True, + "ALLOW_LOGIN_NOT_VERIFIED": False, + "EMAIL_FROM": "no-reply@pledge4future.org", + "ACTIVATION_PATH_ON_EMAIL": os.getenv("PUBLIC_URL", "https://localhost") + "/confirm-email", + "PASSWORD_RESET_PATH_ON_EMAIL": os.getenv("PUBLIC_URL", "https://localhost") + "/set-new-password", + "ACTIVATION_SECONDARY_EMAIL_PATH_ON_EMAIL": os.getenv("PUBLIC_URL", "https://localhost") + "/activate-secondary", + "PASSWORD_SET_PATH_ON_EMAIL": os.getenv("PUBLIC_URL", "https://localhost") + "/set-password", +} + +# EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_USE_TLS = True +EMAIL_USE_SSL = False +EMAIL_PORT = os.environ.get("EMAIL_PORT") +EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER") +EMAIL_HOST = os.environ.get("EMAIL_HOST") +EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD") + +# Internationalization +# https://docs.djangoproject.com/en/3.1/topics/i18n/ + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.1/howto/static-files/ + + +GRAPH_MODELS = { + "all_applications": True, + "group_models": True, +} + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + }, + }, + 'root': { + 'handlers': ['console'], + 'level': 'INFO', + }, +} \ No newline at end of file diff --git a/backend/src/pledge4future/test_settings.py b/backend/src/pledge4future/test_settings.py new file mode 100644 index 00000000..9c206d2c --- /dev/null +++ b/backend/src/pledge4future/test_settings.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Django settings for pledg4future project. + +Generated by 'django-admin startproject' using Django 3.1.5. + +For more information on this file, see +https://docs.djangoproject.com/en/3.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.1/ref/settings/ +""" +from datetime import timedelta +from pathlib import Path +import os +from dotenv import load_dotenv, find_dotenv + +import django +from django.utils.encoding import force_str +django.utils.encoding.force_text = force_str + + +# Load settings from ./.env file +# load_dotenv("../../.env", verbose=True) +#load_dotenv(find_dotenv()) + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +STATIC_ROOT = BASE_DIR.parent / "static" +MEDIA_ROOT = BASE_DIR.parent / "media" + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! - is set in local .env file +SECRET_KEY = '213' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = False #os.environ.get("DJANGO_DEBUG") + +ALLOWED_HOSTS = ["http://localhost", "localhost", "http://api.test-pledge4future.heigit.org"] + +CORS_ALLOW_ALL_ORIGINS = False +CORS_ALLOW_CREDENTIALS = False +CORS_ALLOWED_ORIGINS = ["http://localhost:3000","https://test-pledge4future.heigit.org"] + +CSRF_COOKIE_SECURE=True +SESSION_COOKIE_SECURE=True +SECURE_HSTS_SECONDS=30 +SECURE_HSTS_INCLUDE_SUBDOMAINS=True + +# Application definition +INSTALLED_APPS = [ + 'emissions.apps.EmissionsConfig', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "graphene_django", + "graphql_jwt.refresh_token.apps.RefreshTokenConfig", + "graphql_auth", + "django_filters", + "django_extensions", +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.middleware.common.CommonMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware" +] + +ROOT_URLCONF = "pledge4future.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [os.path.join(BASE_DIR, "./templates")], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ] + }, + } +] + +WSGI_APPLICATION = "pledge4future.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/3.1/ref/settings/#databases + +# local database for development +# DATABASES = { +# "default": { +# "ENGINE": "django.db.backends.sqlite3", +# "NAME": os.path.join(BASE_DIR, "db.sqlitedb"), +# } +# } + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "postgres", + "USER": "postgres", + "HOST": "db", + "PORT": 5432, + } +} + +# Password validation +# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" + }, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +STATIC_URL = "/static/" + +AUTH_USER_MODEL = "emissions.CustomUser" + +GRAPHENE = { + "SCHEMA": "emissions.schema.schema", + "MIDDLEWARE": ["graphql_jwt.middleware.JSONWebTokenMiddleware"], +} + +AUTHENTICATION_BACKENDS = [ + "graphql_auth.backends.GraphQLAuthBackend", + "django.contrib.auth.backends.ModelBackend", +] + +GRAPHQL_JWT = { + # "JWT_AUTH_HEADER_NAME": "HTTP_Authorization", + "JWT_AUTH_HEADER_PREFIX": "JWT", + "JWT_VERIFY_EXPIRATION": True, + "JWT_EXPIRATION_DELTA": timedelta(hours=24), + "JWT_REFRESH_EXPIRATION_DELTA": timedelta(days=7), + "JWT_LONG_RUNNING_REFRESH_TOKEN": True, + "JWT_ALLOW_ANY_CLASSES": [ + "graphql_auth.mutations.Register", + "graphql_auth.mutations.VerifyAccount", + "graphql_auth.mutations.ResendActivationEmail", + "graphql_auth.mutations.SendPasswordResetEmail", + "graphql_auth.mutations.PasswordReset", + "graphql_auth.mutations.ObtainJSONWebToken", + "graphql_auth.mutations.VerifyToken", + "graphql_auth.mutations.RefreshToken", + "graphql_auth.mutations.RevokeToken", + "graphql_auth.mutations.VerifySecondaryEmail", + ], +} + +GRAPHQL_AUTH = { + "LOGIN_ALLOWED_FIELDS": ["email"], + "REGISTER_MUTATION_FIELDS": [ + "email", + "first_name", + "last_name", + ], + "REGISTER_MUTATION_FIELDS_OPTIONAL": ["academic_title", "first_name", "last_name"], + "UPDATE_MUTATION_FIELDS": [ + "first_name", + "last_name", + "academic_title" + ], # "is_representative", "working_group" - make separate mutation + "ALLOW_DELETE_ACCOUNT": True, + "SEND_ACTIVATION_EMAIL": True, + "ALLOW_LOGIN_NOT_VERIFIED": False, + "EMAIL_FROM": "no-reply@pledge4future.org", + "ACTIVATION_PATH_ON_EMAIL": os.getenv("PUBLIC_URL", "https://localhost") + "/confirm-email", + "PASSWORD_RESET_PATH_ON_EMAIL": os.getenv("PUBLIC_URL", "https://localhost") + "/set-new-password", + "ACTIVATION_SECONDARY_EMAIL_PATH_ON_EMAIL": os.getenv("PUBLIC_URL", "https://localhost") + "/activate-secondary", + "PASSWORD_SET_PATH_ON_EMAIL": os.getenv("PUBLIC_URL", "https://localhost") + "/set-password", +} + +# EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_USE_TLS = True +EMAIL_USE_SSL = False +EMAIL_PORT = 587 +EMAIL_HOST_USER = "fill_in" +EMAIL_HOST = "fill_in" +EMAIL_HOST_PASSWORD = 'fill_in' + + + +# Internationalization +# https://docs.djangoproject.com/en/3.1/topics/i18n/ + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.1/howto/static-files/ + + +GRAPH_MODELS = { + "all_applications": True, + "group_models": True, +} + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + }, + }, + 'root': { + 'handlers': ['console'], + 'level': 'INFO', + }, +} \ No newline at end of file diff --git a/backend/src/wepledge/urls.py b/backend/src/pledge4future/urls.py similarity index 74% rename from backend/src/wepledge/urls.py rename to backend/src/pledge4future/urls.py index 9e4052cc..065070ff 100644 --- a/backend/src/wepledge/urls.py +++ b/backend/src/pledge4future/urls.py @@ -1,4 +1,7 @@ -"""wepledge URL Configuration +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""pledge4future URL Configuration The `urlpatterns` list routes URLs to views. For more information please see: https://docs.djangoproject.com/en/3.1/topics/http/urls/ @@ -13,6 +16,7 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ + from django.contrib import admin from django.urls import path from graphene_django.views import GraphQLView @@ -22,9 +26,9 @@ from rest_framework_jwt.views import verify_jwt_token urlpatterns = [ - path('admin/', admin.site.urls), - path('graphql/', csrf_exempt(GraphQLView.as_view(graphiql=True))), - path('api-token-auth/', obtain_jwt_token), - path('api-token-refresh/', refresh_jwt_token), - path('api-token-verify/', verify_jwt_token) + path("admin/", admin.site.urls), + path("graphql/", csrf_exempt(GraphQLView.as_view(graphiql=True))), + path("api-token-auth/", obtain_jwt_token), + path("api-token-refresh/", refresh_jwt_token), + path("api-token-verify/", verify_jwt_token), ] diff --git a/backend/src/wepledge/wsgi.py b/backend/src/pledge4future/wsgi.py similarity index 64% rename from backend/src/wepledge/wsgi.py rename to backend/src/pledge4future/wsgi.py index 42ce972a..9a74a5bd 100644 --- a/backend/src/wepledge/wsgi.py +++ b/backend/src/pledge4future/wsgi.py @@ -1,5 +1,8 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + """ -WSGI config for wepledge project. +WSGI config for pledge4future project. It exposes the WSGI callable as a module-level variable named ``application``. @@ -11,6 +14,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'wepledge.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pledge4future.settings") application = get_wsgi_application() diff --git a/backend/src/preprocessing/make_research_field_fixture.py b/backend/src/preprocessing/make_research_field_fixture.py new file mode 100644 index 00000000..73d53dcb --- /dev/null +++ b/backend/src/preprocessing/make_research_field_fixture.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Make a fixture file for research fields""" + +import json +import copy + + +file = "/data/research_fields.json" +outfile = "./backend/src/emissions/fixtures/research_fields.json" + +with open(file) as src: + data = json.load(src) + +items = [] +item_template = { + "model": "emissions.researchfield", + "pk": None, + "fields": {"field": None, "subfield": None}, +} +pk = 1 + +for field in data.keys(): + print(field) + for subfield in data[field]: + print(subfield) + new_item = copy.deepcopy(item_template) + new_item["pk"] = pk + new_item["fields"]["field"] = field + new_item["fields"]["subfield"] = subfield + items.append(new_item) + pk += 1 + +with open(outfile, "w") as dst: + json.dump(items, dst, indent=2) diff --git a/backend/src/pytest.ini b/backend/src/pytest.ini new file mode 100644 index 00000000..e401be2f --- /dev/null +++ b/backend/src/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +DJANGO_SETTINGS_MODULE = pledge4future.test_settings \ No newline at end of file diff --git a/backend/src/templates/email/activation_email.html b/backend/src/templates/email/activation_email.html new file mode 100644 index 00000000..fed880cf --- /dev/null +++ b/backend/src/templates/email/activation_email.html @@ -0,0 +1,240 @@ + + + + +
+
|
+
+
|
+
+
|
+